Command HQ shows what plugins offer, and what has not been started

the UI half. a row per plugin that ships commanders, below the owner's own
categories and separated from them — because they are not the same kind of thing.
a category is the owner's filing and they may rename it; a plugin is where
something came from and they may not. mixed into one list, "av-club" reads as a
folder somebody made, and the first thing you do with a folder you made is rename
it.

offers sit ABOVE the grid rather than in it. a commander in the grid runs, has a
transcript and has cost money; an offer is a description on disk. drawn as the same
card with a badge, the two look interchangeable, and the first consequence is
somebody hunting for the transcript of a thing that has never run.

an offer STAYS after it is used. it is a template and there is no reason to have
only one — two folders watched for two formats is two commanders from one offer,
and hiding it would make the second look impossible.

the form is drawn from the template's own `inputs`. nothing in the component knows
what av-club wants: options become buttons, boolean becomes yes/no, everything else
is a field. the button names what is missing rather than marking it red.

TWO REAL BUGS FOUND BY WIRING IT UP, both in the list route:

`?category=plugin:av-club` reached `Number('plugin:av-club')` — NaN — and the query
compared `category_id = NaN`. that matches nothing and looks exactly like "this
plugin has no commanders", which is the worst way for a filter to fail. parsed
before the numeric branch now.

and the grid DROPS every row without a transcript. that was right when the only way
to get a row was to start a conversation; it is wrong now, because a commander from
a template arrives deliberately not running — so the thing somebody just created was
invisible, under a toast saying it worked. a row with a `plugin` and no session is
now projected from the row itself, with the zeroes that are true of it, and sorted
FIRST: it is the one that needs a decision. ordinary rows keep the old behaviour,
since a hand-made row with no transcript is a conversation that failed to start and
the design refuses to draw a tombstone for it.

four locale keys, all six languages, parity test green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 03:10:29 +01:00
co-authored by Claude Opus 5
parent 766b18b7f9
commit 347acf0171
15 changed files with 412 additions and 23 deletions
@@ -63,6 +63,8 @@ export type CommandHqSessionRow = {
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
name: string | null;
categoryId: number | null;
plugin: string | null;
pluginConfig: Record<string, string> | null;
watcherArmed: boolean;
watcherTaskId: string | null;
watcherArmedAt: Date | null;
@@ -78,6 +80,8 @@ const SESSION_ROW_COLUMNS = {
referencePaths: commandHqSessions.referencePaths,
name: commandHqSessions.name,
categoryId: commandHqSessions.categoryId,
plugin: commandHqSessions.plugin,
pluginConfig: commandHqSessions.pluginConfig,
watcherArmed: commandHqSessions.watcherArmed,
watcherTaskId: commandHqSessions.watcherTaskId,
watcherArmedAt: commandHqSessions.watcherArmedAt,
@@ -166,9 +170,12 @@ export async function getCommandHqSessionById(userId: number, id: number): Promi
export async function listCommandHqSessions(
userId: number,
categoryId?: number | null,
/** Instead of a category: only commanders shipped by this plugin. The two never combine. */
plugin?: string,
): Promise<CommandHqSessionRow[]> {
const where =
categoryId === undefined
const where = plugin
? and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.plugin, plugin))
: categoryId === undefined
? eq(commandHqSessions.userId, userId)
: categoryId === null
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
+63 -3
View File
@@ -203,6 +203,17 @@ type CommanderSummary = ClaudeSessionSummary & {
watcherLastFiredAt: Date | null;
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
cost: CommandHqCostSummary;
/** Where it came from, when a plugin's template made it. Null for one somebody wrote themselves. */
plugin?: string | null;
/** The owner's answers to that template's inputs, so the card can say what it is set to watch. */
pluginConfig?: Record<string, string> | null;
/**
* Created, never opened — so there is no transcript behind it and its id is not a session id.
*
* Only ever true for a plugin's commander. A hand-made row without a transcript is one whose
* conversation failed to start, and the design deliberately refuses to draw a tombstone for it.
*/
pending?: boolean;
};
async function isRunning(sessionId: string): Promise<boolean> {
@@ -224,7 +235,15 @@ commandHqRouter.get('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const categoryParam = ctx.req.query('category');
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
/*
`plugin:<appName>` is a filter on provenance, not a category, and is parsed BEFORE the numeric branch.
Without this it reached `Number('plugin:av-club')` — NaN — and the query compared `category_id = NaN`,
which matches nothing and looks exactly like "this plugin has no commanders".
*/
const pluginFilter = categoryParam?.startsWith('plugin:') ? categoryParam.slice('plugin:'.length) : undefined;
const categoryId =
pluginFilter || categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
/*
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
@@ -237,7 +256,7 @@ commandHqRouter.get('/commanders', async (ctx) => {
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
*/
const rows = await listCommandHqSessions(user.id, categoryId);
const rows = await listCommandHqSessions(user.id, categoryId, pluginFilter);
const withTranscript = rows.filter((row) => row.sessionId !== null);
const summaries = withTranscript
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
@@ -260,6 +279,39 @@ commandHqRouter.get('/commanders', async (ctx) => {
),
]);
/*
A commander CREATED but never opened has no transcript, and every branch above drops it.
That was right when the only way to get a row was to start a conversation. It is wrong now: a
commander made from a plugin's template arrives deliberately not running, and dropping it means the
thing somebody just created is invisible — with a toast saying it worked. So a row with a `plugin`
and no session is projected from the ROW alone, with the zeroes that are actually true of it.
Ordinary commanders keep the old behaviour. A hand-made row with no transcript is a row whose
conversation failed to start, and a tombstone for it is what the design deliberately refused.
*/
const pending = rows
.filter((row) => row.sessionId === null && row.plugin)
.map(
(row) =>
({
id: `pending-${row.id}`,
title: row.name ?? 'Commander',
dirName: row.dirName,
categoryId: row.categoryId,
tagIds: [],
running: false,
watcherArmed: false,
watcherArmedAt: null,
watcherStoppedAt: null,
watcherLastFiredAt: null,
cost: { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
plugin: row.plugin,
pluginConfig: row.pluginConfig,
pending: true,
}) as unknown as CommanderSummary,
);
const commanders: CommanderSummary[] = filed
.map((row) => ({
...liveById.get(row.sessionId!)!,
@@ -279,6 +331,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
// beside the transcript.
// Provenance, so the UI can put a plugin's commanders in their own section rather than inferring
// it from a category name somebody can rename.
plugin: row.plugin,
pluginConfig: row.pluginConfig,
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
@@ -290,7 +346,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return ctx.json({ commanders });
// Pending ones FIRST: a commander that was just created and needs a decision is the thing somebody came
// to the screen for, and sorting it by `updatedAt` among running ones would bury it on a busy account.
return ctx.json({ commanders: [...pending, ...commanders] });
});
/**
@@ -465,6 +523,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
plugin: row.plugin,
pluginConfig: row.pluginConfig,
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
watcherArmedAt: row.watcherArmedAt,
@@ -4,6 +4,7 @@ import { Plus } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { errorText } from 'helpers/error-text';
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
import { usePluginCommanderTemplates } from './useCommandHq';
import { useCommandHqCategories } from './useCommandHq';
import type { CommandHqCategoryFilter } from './types';
import { useTranslation } from '../../locales';
@@ -36,6 +37,11 @@ export const CommandHqCategories = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
// Which plugins have anything to offer. Derived from the templates rather than from the installed
// plugin list: a plugin that ships no commander should not get a row that is always empty.
const { templates } = usePluginCommanderTemplates();
const plugins = [...new Set(templates.map((template) => template.plugin))].sort();
const { categories, createCategory } = useCommandHqCategories();
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
@@ -96,6 +102,34 @@ export const CommandHqCategories = () => {
to={commandHqListPath(category.id)}
/>
))}
{/*
One row per plugin that offers commanders, below the owner's own categories and separated from
them.
Separated because they are not the same kind of thing. A category is the owner's filing and they
can rename or delete it; a plugin is where something came from and they cannot. Mixing them into
one list would invite the reasonable assumption that "av-club" is a folder they made, and the
first thing somebody does with a folder they made is rename it.
Shown even when the plugin has no commanders yet — that is the whole point. The row is how you
find out a plugin is offering one.
*/}
{plugins.length > 0 && (
<div className="mt-2 border-t border-border pt-2">
<span className="block px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
{t('commandHq.fromPlugins')}
</span>
{plugins.map((plugin) => (
<Row
key={plugin}
label={plugin}
active={active === `plugin:${plugin}`}
to={commandHqListPath(`plugin:${plugin}`)}
/>
))}
</div>
)}
{adding && (
// No submit button — a form with exactly one text field still submits on Enter.
<form onSubmit={submitNew}>
@@ -20,7 +20,7 @@ import {
useCommandHqCommanderActions,
useCreateCommander,
} from './useCommandHq';
import type { CommandHqCommanderCreated } from './types';
import type { CommandHqCommanderCreated, CommandHqCategoryFilter } from './types';
import { formatCommanderCost, formatTokens } from './format';
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
import { useTranslation } from '../../locales';
@@ -246,7 +246,7 @@ function NewCommanderSetup({
category,
onCreated,
}: {
category: number | 'none' | undefined;
category: CommandHqCategoryFilter;
onCreated: (commander: CommandHqCommanderCreated) => void;
}) {
/*
@@ -335,7 +335,7 @@ function ActiveNewCommander({
onStarted,
}: {
commander: CommandHqCommanderCreated;
category: number | 'none' | undefined;
category: CommandHqCategoryFilter;
onStarted: (id: string) => void;
}) {
const { t, tUi, locale } = useTranslation();
@@ -446,13 +446,7 @@ function ActiveNewCommander({
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
* socket dropped").
*/
function NewCommander({
category,
onStarted,
}: {
category: number | 'none' | undefined;
onStarted: (id: string) => void;
}) {
function NewCommander({ category, onStarted }: { category: CommandHqCategoryFilter; onStarted: (id: string) => void }) {
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
@@ -5,6 +5,7 @@ import { Check, Plus, Trash2, X } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { errorText } from 'helpers/error-text';
import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
import { PluginCommanderOffers } from './PluginCommanderOffers';
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
import { formatCommanderCost, formatTokens } from './format';
import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
@@ -170,6 +171,12 @@ export const CommandHqGrid = () => {
row.
*/}
<div className="flex flex-col gap-3 p-4">
{/* Only on a plugin's own filter. On "All commanders" this would put every plugin's offers above
a list that is mostly not theirs, which is a different screen pretending to be this one. */}
{typeof category === 'string' && category.startsWith('plugin:') && (
<PluginCommanderOffers plugin={category.slice('plugin:'.length)} />
)}
{commanders.map((commander) => (
<Card
key={commander.id}
@@ -0,0 +1,198 @@
import { useState } from 'react';
import { Loader2, Package, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslation } from 'officerdev';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { usePluginCommanderTemplates } from './useCommandHq';
import type { PluginCommanderTemplate } from './types';
/**
* What a plugin offers but has not been started.
*
* ── Offers sit ABOVE the grid, not inside it ──
*
* A commander in the grid is running, has a transcript and has cost something. An offer is a
* description on disk. Drawing them as the same kind of card, distinguished by a badge, makes the two
* look interchangeable — and the first consequence is somebody looking for the transcript of a thing
* that has never run.
*
* ── An offer stays visible after it has been used ──
*
* Deliberately: a plugin's commander is a template, and there is no reason to have only one. Watching
* two different folders for two different formats is two commanders from the same offer, and hiding
* the offer once used would make the second one look impossible.
*/
export const PluginCommanderOffers = ({ plugin }: { plugin: string }) => {
const { t } = useTranslation();
const { templates, createFromTemplate } = usePluginCommanderTemplates();
const [configuring, setConfiguring] = useState<PluginCommanderTemplate | null>(null);
const offers = templates.filter((template) => template.plugin === plugin);
if (offers.length === 0) return null;
return (
<div className="border-b border-border">
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
<Package className="size-3.5 text-muted-foreground" />
<span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{t('commandHq.available')}
</span>
</div>
<div className="flex flex-col gap-2 p-3 pt-1">
{offers.map((offer) => (
<div key={offer.dirName} className="rounded-lg border border-border bg-card p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{offer.name}</p>
<p className="text-xs text-muted-foreground">{offer.description}</p>
{offer.watches && <p className="pt-0.5 text-xs text-muted-foreground/70">{offer.watches}</p>}
</div>
<Button
size="sm"
variant={configuring?.dirName === offer.dirName ? 'secondary' : 'outline'}
className="shrink-0"
onClick={() => setConfiguring(configuring?.dirName === offer.dirName ? null : offer)}
>
<Plus className="size-3.5" />
{t('commandHq.configureAndCreate')}
</Button>
</div>
{configuring?.dirName === offer.dirName && (
<ConfigureForm
template={offer}
busy={createFromTemplate.isPending}
onCancel={() => setConfiguring(null)}
onSubmit={(name, config) => {
createFromTemplate.mutate(
{ plugin: offer.plugin, template: offer.dirName, name, config },
{
onSuccess: () => {
setConfiguring(null);
// Named, because it is now in the grid below among things that look alike.
toast.success(`${name} created — it is not running yet`);
},
onError: (err: unknown) =>
toast.error(err instanceof Error ? err.message : 'could not create it'),
},
);
}}
/>
)}
</div>
))}
</div>
</div>
);
};
/**
* The template's own inputs, drawn from its declaration.
*
* Nothing here knows what av-club's commander wants. `options` becomes a select, `boolean` becomes
* yes/no, everything else is a text field — the same three shapes a task's inputs use, because they
* are the same declaration.
*/
const ConfigureForm = ({
template,
busy,
onCancel,
onSubmit,
}: {
template: PluginCommanderTemplate;
busy: boolean;
onCancel: () => void;
onSubmit: (name: string, config: Record<string, string>) => void;
}) => {
const { t } = useTranslation();
const [name, setName] = useState(template.name);
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(Object.entries(template.inputs).map(([key, spec]) => [key, spec.default ?? ''])),
);
const missing = Object.entries(template.inputs)
.filter(([key, spec]) => spec.required && !values[key]?.trim())
.map(([key]) => key);
return (
<form
className="mt-3 flex flex-col gap-3 border-t border-border pt-3"
onSubmit={(event) => {
event.preventDefault();
if (missing.length === 0 && name.trim()) onSubmit(name.trim(), values);
}}
>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t('commandHq.name')}</span>
<Input value={name} onChange={(event) => setName(event.target.value)} className="text-sm" />
</label>
{Object.entries(template.inputs).map(([key, spec]) => (
<label key={key} className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{key.replace(/_/g, ' ')}
{spec.required && <span className="text-destructive"> *</span>}
</span>
{spec.options ? (
<div className="flex flex-wrap gap-1">
{spec.options.map((option) => (
<button
key={option}
type="button"
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
values[key] === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
}`}
>
{option}
</button>
))}
</div>
) : spec.type === 'boolean' ? (
<div className="flex gap-1">
{['false', 'true'].map((option) => (
<button
key={option}
type="button"
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
(values[key] || 'false') === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
}`}
>
{option === 'true' ? 'Yes' : 'No'}
</button>
))}
</div>
) : (
<Input
value={values[key] ?? ''}
onChange={(event) => setValues((v) => ({ ...v, [key]: event.target.value }))}
placeholder={spec.type === 'directory' ? '/home/you/folder' : ''}
className="font-mono text-xs"
/>
)}
{spec.description && <span className="text-[11px] text-muted-foreground/70">{spec.description}</span>}
</label>
))}
<div className="flex items-center gap-2">
<Button type="submit" size="sm" disabled={busy || missing.length > 0 || !name.trim()}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
{t('commandHq.configureAndCreate')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
{/* Named, so it is obvious WHICH field is stopping the button rather than hunting for a red mark. */}
{missing.length > 0 && (
<span className="text-xs text-muted-foreground">needs {missing.join(', ').replace(/_/g, ' ')}</span>
)}
</div>
</form>
);
};
@@ -40,6 +40,9 @@ export const commandHqSessionPath = (sessionId: string, category?: CommandHqCate
export const categoryFromParam = (raw: string | null): CommandHqCategoryFilter => {
if (raw === null) return undefined;
if (raw === 'none') return 'none';
// `plugin:av-club`. Checked before the numeric parse, since an appName never parses as a number and
// would otherwise fall through to `undefined` and silently show every commander.
if (raw.startsWith('plugin:') && raw.length > 'plugin:'.length) return raw as CommandHqCategoryFilter;
const id = Number(raw);
return Number.isFinite(id) ? id : undefined;
};
@@ -26,8 +26,16 @@ export type CommandHqCostSummary = {
totalUsd: number;
};
/** `category` query value: omitted (all), `'none'` (uncategorised), or a category id. */
export type CommandHqCategoryFilter = number | 'none' | undefined;
/**
* `category` query value: omitted (all), `'none'` (uncategorised), a category id, or `plugin:<appName>`.
*
* The plugin form is not a category and deliberately does not pretend to be one. A commander's category
* is the owner's filing; which plugin shipped it is a fact about where it came from, and the two are
* independent — a plugin's commander can also be filed under "Nightly" without either meaning changing.
* One query parameter carries both because they are both "which subset of the grid", and that is what
* this parameter has always meant.
*/
export type CommandHqCategoryFilter = number | 'none' | `plugin:${string}` | undefined;
/** One grid card — `ClaudeSessionSummary` plus what Command HQ knows about it. See `GET /commanders`. */
export type CommandHqCommander = {
@@ -100,3 +108,23 @@ export type CommandHqFire = {
taskId: string;
firedAt: string;
};
/**
* A commander a plugin OFFERS but which does not exist yet.
*
* Deliberately a different type from `CommandHqCommander`, and fetched from a different endpoint. One
* runs, has a transcript and costs money; the other is a description on disk. Modelling them as one
* type with an `exists` flag would mean every consumer checking the flag before touching anything, and
* the one that forgets renders a card with a cost of zero for something that was never started.
*/
export type PluginCommanderTemplate = {
dirName: string;
plugin: string;
name: string;
description: string;
watches?: string;
inputs: Record<
string,
{ type: string; description?: string; default?: string; options?: string[]; required?: boolean }
>;
};
@@ -6,6 +6,7 @@ import type {
CommandHqCommander,
CommandHqCommanderCreated,
CommandHqCommanderDetail,
PluginCommanderTemplate,
} from './types';
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
@@ -173,3 +174,36 @@ export function useCommandHqChildren(sessionId: string | undefined) {
isLoading,
};
}
/**
* What the installed plugins offer, and making one real.
*
* The list is cheap and changes only when a plugin is installed, updated or disabled — so it is not
* refetched on a window focus. A plugin the owner just installed appears when Command HQ next mounts,
* which is the same moment its dock tile does.
*/
export const usePluginCommanderTemplates = () => {
const { get, post } = useClient();
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ templates: PluginCommanderTemplate[] }>({
queryKey: ['command-hq', 'plugin-commanders'],
queryFn: () => get('/command-hq/plugin-commanders'),
staleTime: 60_000,
});
const createFromTemplate = useMutation({
mutationFn: (input: { plugin: string; template: string; name: string; config: Record<string, string> }) =>
post<CommandHqCommanderCreated>('/command-hq/commanders', input),
// The grid reads `commanders`, and a newly created one has to appear in it without a reload —
// creating from a template is the one moment somebody is certain something should have happened.
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
});
return { templates: data?.templates ?? EMPTY_TEMPLATES, isLoading, createFromTemplate };
};
// Module-level, so an empty result is the SAME array every render. A fresh `[]` in the fallback is a new
// identity each time, which re-runs every effect and memo that depends on it — the trap this file already
// avoids for categories and commanders.
const EMPTY_TEMPLATES: PluginCommanderTemplate[] = [];
@@ -257,7 +257,11 @@
},
"startHint": "Er sieht sich zunächst an, worauf Sie ihn hingewiesen haben, und fragt Sie dann, worauf er achten soll.",
"startBlocked": "{{missing}} — dann startet dies ihn.",
"confirmDeletingNamed": "Löschen von {{name}} bestätigen"
"confirmDeletingNamed": "Löschen von {{name}} bestätigen",
"fromPlugins": "Von Plugins",
"available": "Verfügbar",
"configureAndCreate": "Einrichten und erstellen",
"notCreatedYet": "Noch nicht erstellt — richten Sie es ein, um zu starten."
},
"chatHistory": {
"newChat": "Neuer Chat",
@@ -257,7 +257,11 @@
},
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
"startBlocked": "{{missing}} — then this starts it.",
"confirmDeletingNamed": "Confirm deleting {{name}}"
"confirmDeletingNamed": "Confirm deleting {{name}}",
"fromPlugins": "From plugins",
"available": "Available",
"configureAndCreate": "Configure and create",
"notCreatedYet": "Not created yet — configure it to start."
},
"chatHistory": {
"newChat": "New Chat",
@@ -257,7 +257,11 @@
},
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}"
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}",
"fromPlugins": "De plugins",
"available": "Disponible",
"configureAndCreate": "Configurar y crear",
"notCreatedYet": "Aún no creado — configúralo para empezar."
},
"chatHistory": {
"newChat": "Nuevo chat",
@@ -257,7 +257,11 @@
},
"startHint": "Il va regarder ce vers quoi vous l'avez orienté, puis vous demander ce qu'il doit surveiller.",
"startBlocked": "{{missing}} — ensuite ceci le démarre.",
"confirmDeletingNamed": "Confirmer la suppression de {{name}}"
"confirmDeletingNamed": "Confirmer la suppression de {{name}}",
"fromPlugins": "Depuis les plugins",
"available": "Disponible",
"configureAndCreate": "Configurer et créer",
"notCreatedYet": "Pas encore créé — configurez-le pour commencer."
},
"chatHistory": {
"newChat": "Nouveau Chat",
@@ -257,7 +257,11 @@
},
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
"startBlocked": "{{missing}} — depois é isto que o inicia.",
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}"
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}",
"fromPlugins": "De plugins",
"available": "Disponível",
"configureAndCreate": "Configurar e criar",
"notCreatedYet": "Ainda não criado — configure para começar."
},
"chatHistory": {
"newChat": "Novo chat",
@@ -257,7 +257,11 @@
},
"startHint": "Den tittar på det du pekade den mot, och frågar dig sedan vad den ska bevaka.",
"startBlocked": "{{missing}} — sedan startar detta den.",
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}"
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}",
"fromPlugins": "Från plugin",
"available": "Tillgänglig",
"configureAndCreate": "Konfigurera och skapa",
"notCreatedYet": "Inte skapad än — konfigurera för att starta."
},
"chatHistory": {
"newChat": "Ny chatt",