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:
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Context } from 'hono';
|
||||||
|
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
|
||||||
|
|
||||||
|
// One answer to "which directory is this chat about", for the two routers that need it.
|
||||||
|
//
|
||||||
|
// It lived in chat.ts as a private helper until 2026-08-27, when Command HQ grew a button that starts a
|
||||||
|
// commander from a plugin chat. That commander watches the directory the chat runs in — and the whole
|
||||||
|
// reason this function exists is that the CLIENT cannot name that directory. Two copies of the mapping
|
||||||
|
// would be two chances for a commander to end up watching somewhere its chat has never been.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which directory a listing is about.
|
||||||
|
*
|
||||||
|
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
|
||||||
|
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
|
||||||
|
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
|
||||||
|
* its own directory and then has no way to ask for them back.
|
||||||
|
*
|
||||||
|
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
|
||||||
|
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
|
||||||
|
* another directory shows you your own conversations elsewhere, never anybody else's.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The same mapping, from values rather than from a request.
|
||||||
|
*
|
||||||
|
* Command HQ takes the context in a JSON BODY — it is creating a commander, not listing anything — and
|
||||||
|
* the request-reading version silently fell through to `who.home` for it, because there was nothing in
|
||||||
|
* the query string. A commander watching the wrong directory is not an error anywhere: it just answers
|
||||||
|
* questions about the wrong thing forever.
|
||||||
|
*/
|
||||||
|
export const chatContextDir = (
|
||||||
|
context: string | undefined,
|
||||||
|
contextId: string | undefined,
|
||||||
|
who: { home: string; email: string },
|
||||||
|
): string => {
|
||||||
|
const id = contextId?.trim();
|
||||||
|
if (context === 'plugin' && id) return getPluginChatsDir(who.email, id);
|
||||||
|
if (context === 'bots') return getBotsDir(who.email);
|
||||||
|
if (context === 'email') return resolveEmailAccountDir(who.email, id);
|
||||||
|
return who.home;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveChatContextDir = (ctx: Context, who: { home: string; email: string }): string => {
|
||||||
|
const context = ctx.req.query('context');
|
||||||
|
const contextId = ctx.req.query('contextId')?.trim();
|
||||||
|
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
|
||||||
|
// Every bot shares one directory, so this needs no id — the context IS the address.
|
||||||
|
if (context === 'bots') return getBotsDir(who.email);
|
||||||
|
// An email chat runs in the MAILBOX's directory, beside `emails.db`. `contextId` is the account picked on
|
||||||
|
// screen, and is optional: without one the resolver takes the most recently synced account.
|
||||||
|
//
|
||||||
|
// This branch was missing, and the failure was quiet in the way that costs the most time — the context
|
||||||
|
// fell through to `who.home`, so the picker above an email composer listed the home's conversations. A
|
||||||
|
// message sent into the mailbox then could not be found in the list directly above it. Same resolver as
|
||||||
|
// the socket, so the turn and the list cannot disagree about where the chat lives.
|
||||||
|
if (context === 'email') return resolveEmailAccountDir(who.email, contextId);
|
||||||
|
return ctx.req.query('cwd')?.trim() || who.home;
|
||||||
|
};
|
||||||
@@ -91,6 +91,7 @@ export async function chatIdentity(user: { id: number; email: string }): Promise
|
|||||||
// owner's home and Linux account. It is the same blocker, not a new one, but it is easy to miss because it
|
// owner's home and Linux account. It is the same blocker, not a new one, but it is easy to miss because it
|
||||||
// lives in a sidecar rather than in this file. Whoever does the identity work: all three move together.
|
// lives in a sidecar rather than in this file. Whoever does the identity work: all three move together.
|
||||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||||
|
import { resolveChatContextDir } from './chat-context';
|
||||||
|
|
||||||
export const chatRouter = createRouter();
|
export const chatRouter = createRouter();
|
||||||
|
|
||||||
@@ -117,35 +118,9 @@ export const chatRouter = createRouter();
|
|||||||
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
||||||
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
|
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
|
||||||
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
|
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
|
||||||
// selects there too.
|
// selects there too. The resolver moved to `chat-context.ts` when Command HQ needed the same answer —
|
||||||
/**
|
// a commander started from a plugin chat watches the directory that chat runs in, and only the server
|
||||||
* Which directory a listing is about.
|
// can turn a context into a path.
|
||||||
*
|
|
||||||
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
|
|
||||||
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
|
|
||||||
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
|
|
||||||
* its own directory and then has no way to ask for them back.
|
|
||||||
*
|
|
||||||
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
|
|
||||||
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
|
|
||||||
* another directory shows you your own conversations elsewhere, never anybody else's.
|
|
||||||
*/
|
|
||||||
const cwdOf = (ctx: Context, who: { home: string; email: string }): string => {
|
|
||||||
const context = ctx.req.query('context');
|
|
||||||
const contextId = ctx.req.query('contextId')?.trim();
|
|
||||||
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
|
|
||||||
// Every bot shares one directory, so this needs no id — the context IS the address.
|
|
||||||
if (context === 'bots') return getBotsDir(who.email);
|
|
||||||
// An email chat runs in the MAILBOX's directory, beside `emails.db`. `contextId` is the account picked on
|
|
||||||
// screen, and is optional: without one the resolver takes the most recently synced account.
|
|
||||||
//
|
|
||||||
// This branch was missing, and the failure was quiet in the way that costs the most time — the context
|
|
||||||
// fell through to `who.home`, so the picker above an email composer listed the home's conversations. A
|
|
||||||
// message sent into the mailbox then could not be found in the list directly above it. Same resolver as
|
|
||||||
// the socket, so the turn and the list cannot disagree about where the chat lives.
|
|
||||||
if (context === 'email') return resolveEmailAccountDir(who.email, contextId);
|
|
||||||
return ctx.req.query('cwd')?.trim() || who.home;
|
|
||||||
};
|
|
||||||
|
|
||||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||||
chatRouter.get('/pwds', async (ctx) => {
|
chatRouter.get('/pwds', async (ctx) => {
|
||||||
@@ -158,7 +133,7 @@ chatRouter.get('/pwds', async (ctx) => {
|
|||||||
chatRouter.get('/sessions', async (ctx) => {
|
chatRouter.get('/sessions', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const who = await chatIdentity(user);
|
const who = await chatIdentity(user);
|
||||||
const cwd = cwdOf(ctx, who);
|
const cwd = resolveChatContextDir(ctx, who);
|
||||||
|
|
||||||
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
|
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
|
||||||
// separate route because the response shape is identical and every consumer downstream (rename, delete,
|
// separate route because the response shape is identical and every consumer downstream (rename, delete,
|
||||||
@@ -242,7 +217,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
|||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const who = await chatIdentity(user);
|
const who = await chatIdentity(user);
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, who);
|
const cwd = resolveChatContextDir(ctx, who);
|
||||||
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
|
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
|
||||||
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
|
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
|
||||||
// 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read
|
// 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read
|
||||||
@@ -455,7 +430,7 @@ chatRouter.get('/live', async (ctx) => {
|
|||||||
chatRouter.delete('/sessions/:id', async (ctx) => {
|
chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||||
const who = await chatIdentity(ctx.get('user'));
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, who);
|
const cwd = resolveChatContextDir(ctx, who);
|
||||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||||
if (isOpenCodeSessionId(id)) {
|
if (isOpenCodeSessionId(id)) {
|
||||||
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
|
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
|
||||||
@@ -520,7 +495,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
|||||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||||
const who = await chatIdentity(ctx.get('user'));
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, who);
|
const cwd = resolveChatContextDir(ctx, who);
|
||||||
const { title } = await ctx.req.json<{ title?: string }>();
|
const { title } = await ctx.req.json<{ title?: string }>();
|
||||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type ClaudeSessionSummary,
|
type ClaudeSessionSummary,
|
||||||
} from '../chat/claude-sessions';
|
} from '../chat/claude-sessions';
|
||||||
import { collectRunningTasks, startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
|
import { collectRunningTasks, startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
|
||||||
|
import { chatContextDir } from '../chat/chat-context';
|
||||||
import {
|
import {
|
||||||
listCommandHqCategories,
|
listCommandHqCategories,
|
||||||
createCommandHqCategory,
|
createCommandHqCategory,
|
||||||
@@ -527,13 +528,33 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
|
|||||||
commandHqRouter.post('/commanders', async (ctx) => {
|
commandHqRouter.post('/commanders', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const who = await commandHqIdentity(user);
|
const who = await commandHqIdentity(user);
|
||||||
const body = await ctx.req
|
type Body = {
|
||||||
.json<{ name?: string; referencePath?: string; categoryId?: number | null }>()
|
name?: string;
|
||||||
.catch(() => ({}) as { name?: string; referencePath?: string; categoryId?: number | null });
|
referencePath?: string;
|
||||||
|
categoryId?: number | null;
|
||||||
|
/** A chat context to watch, instead of a path — see below. */
|
||||||
|
context?: string;
|
||||||
|
contextId?: string;
|
||||||
|
};
|
||||||
|
const body = await ctx.req.json<Body>().catch(() => ({}) as Body);
|
||||||
const name = body.name?.trim();
|
const name = body.name?.trim();
|
||||||
const referencePath = body.referencePath?.trim();
|
|
||||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
|
|
||||||
|
/*
|
||||||
|
A commander started FROM a chat watches the directory that chat runs in.
|
||||||
|
|
||||||
|
The client sends the context it already has — `{context:'email', contextId:'me@…'}` — and never a
|
||||||
|
path, because it cannot know one: a plugin's chats live under `$DATA_PATH/<email>/plugins/<app>/chats`
|
||||||
|
and an email chat beside its own `emails.db`, both built server-side and deliberately not sent to the
|
||||||
|
browser. `resolveChatContextDir` is the same function the chat router uses, so a commander cannot end
|
||||||
|
up watching a directory its own chat has never been in.
|
||||||
|
|
||||||
|
This is also what makes the feature reachable by somebody who is not a super user. Pointing a
|
||||||
|
commander at a mailbox previously meant knowing its path and typing it, which meant a file browser
|
||||||
|
and an account allowed to walk the disk. The button asks for neither.
|
||||||
|
*/
|
||||||
|
const referencePath = body.context ? chatContextDir(body.context, body.contextId, who) : body.referencePath?.trim();
|
||||||
|
if (!referencePath) throw errors.BAD_REQUEST('referencePath or context is required');
|
||||||
|
|
||||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||||
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useWorkspace, cwdToPath } from '../../components/Workspace';
|
|||||||
import { usePanelSession } from './use-panel-session';
|
import { usePanelSession } from './use-panel-session';
|
||||||
import type { ChatMessage } from './types';
|
import type { ChatMessage } from './types';
|
||||||
import { useTranslation } from '../../locales';
|
import { useTranslation } from '../../locales';
|
||||||
|
import { WatchWithCommanderButton } from './WatchWithCommanderButton';
|
||||||
|
|
||||||
export type ChatSessionSelection = {
|
export type ChatSessionSelection = {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -45,9 +46,15 @@ const title = (s: { title: string }) => s.title || '(untitled)';
|
|||||||
export const ChatHeader = ({
|
export const ChatHeader = ({
|
||||||
panelId,
|
panelId,
|
||||||
chatContext,
|
chatContext,
|
||||||
|
commanderLabel,
|
||||||
}: {
|
}: {
|
||||||
panelId?: string;
|
panelId?: string;
|
||||||
chatContext?: { context: string; contextId?: string } | null;
|
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 { t, tUi } = useTranslation();
|
||||||
const { workspace, cwd } = useWorkspace();
|
const { workspace, cwd } = useWorkspace();
|
||||||
@@ -181,6 +188,10 @@ export const ChatHeader = ({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<span className="shrink-0 truncate text-[10px] font-mono opacity-60">{cwd}</span>
|
<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,
|
name,
|
||||||
onName,
|
onName,
|
||||||
referencePath,
|
referencePath,
|
||||||
|
contextLabel,
|
||||||
onPickReferencePath,
|
onPickReferencePath,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
onName: (next: string) => void;
|
onName: (next: string) => void;
|
||||||
referencePath: string | null;
|
referencePath: string | null;
|
||||||
|
/** Named instead of picked — the reference is a context the server resolves. See `NewCommanderSetup`. */
|
||||||
|
contextLabel?: string | null;
|
||||||
onPickReferencePath: () => void;
|
onPickReferencePath: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { t, tUi } = useTranslation();
|
const { t, tUi } = useTranslation();
|
||||||
@@ -197,6 +200,14 @@ const CommanderSetup = ({
|
|||||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{t('commandHq.referencePath')}
|
{t('commandHq.referencePath')}
|
||||||
</span>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onPickReferencePath}
|
onClick={onPickReferencePath}
|
||||||
@@ -207,9 +218,16 @@ const CommanderSetup = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4 shrink-0" />
|
<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>
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||||
|
{referencePath ?? 'Choose a directory…'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<span className="text-[11px] text-muted-foreground">{t('commandHq.referencePathHint')}</span>
|
)}
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,11 +249,24 @@ function NewCommanderSetup({
|
|||||||
category: number | 'none' | undefined;
|
category: number | 'none' | undefined;
|
||||||
onCreated: (commander: CommandHqCommanderCreated) => void;
|
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 [referencePath, setReferencePath] = useState<string | null>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [creating, setCreating] = 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 createCommander = useCreateCommander();
|
||||||
|
|
||||||
const onStart = async () => {
|
const onStart = async () => {
|
||||||
@@ -244,7 +275,8 @@ function NewCommanderSetup({
|
|||||||
try {
|
try {
|
||||||
const commander = await createCommander.mutateAsync({
|
const commander = await createCommander.mutateAsync({
|
||||||
name: name.trim(),
|
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,
|
categoryId: typeof category === 'number' ? category : undefined,
|
||||||
});
|
});
|
||||||
onCreated(commander);
|
onCreated(commander);
|
||||||
@@ -260,11 +292,14 @@ function NewCommanderSetup({
|
|||||||
name={name}
|
name={name}
|
||||||
onName={setName}
|
onName={setName}
|
||||||
referencePath={referencePath}
|
referencePath={referencePath}
|
||||||
|
contextLabel={context ? (contextLabel ?? context) : null}
|
||||||
onPickReferencePath={() => setPickerOpen(true)}
|
onPickReferencePath={() => setPickerOpen(true)}
|
||||||
/>
|
/>
|
||||||
<StartCommanderButton
|
<StartCommanderButton
|
||||||
ready={ready && !creating}
|
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()}
|
onStart={() => void onStart()}
|
||||||
/>
|
/>
|
||||||
<NewCommanderGuide />
|
<NewCommanderGuide />
|
||||||
|
|||||||
@@ -26,8 +26,20 @@ export function useCreateCommander() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
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'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user