feat(ask): slash shortcuts for base reports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-26 09:56:21 +02:00
parent 1dbe719e2c
commit 534f3b6a85
4 changed files with 401 additions and 23 deletions

View file

@ -6,18 +6,33 @@ import { cn } from "@/lib/utils";
import { AnswerMarkdown } from "./answer-markdown";
import { UploadCard, type UploadState } from "./upload-card";
import type { ChartSpec } from "@/lib/reports/chart-spec";
import {
SLASH_COMMANDS,
argHint,
expandSlashCommand,
matchCommands,
type SlashCommand,
} from "@/lib/chat/slash-commands";
/**
* `text` is what the person sees; a slash command also carries the
* expanded `prompt` the model gets. `local` turns (/help) never reach
* the model.
*/
type Turn =
| { role: "user"; text: string }
| { role: "assistant"; text: string; tools?: string[]; charts: ChartSpec[] }
| { role: "user"; text: string; prompt?: string; local?: boolean }
| { role: "assistant"; text: string; tools?: string[]; charts: ChartSpec[]; local?: boolean }
| { role: "upload"; upload: UploadState };
const SUGGESTIONS = [
"Which vehicles need attention?",
"What's my projected profit on the Hills?",
"How are all my sites doing?",
"What's outstanding for verification?",
];
/** Starter chips: the base reports. */
const COMMAND_CHIPS = ["today", "portfolio", "problems", "findings"]
.map((name) => SLASH_COMMANDS.find((c) => c.name === name))
.filter((c): c is SlashCommand => !!c);
/** What a chip or menu pick puts in the input: args leave the cursor after a space. */
function completion(c: SlashCommand) {
return c.args === "none" ? `/${c.name}` : `/${c.name} `;
}
/** get_site_financials -> "site financials" */
function toolLabel(name: string) {
@ -35,6 +50,10 @@ export function ChatView() {
const [busy, setBusy] = React.useState(false);
const [toolRunning, setToolRunning] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
/** Inline, under the input: a bad slash command (e.g. /site with no site). */
const [slashError, setSlashError] = React.useState<string | null>(null);
const [menuIndex, setMenuIndex] = React.useState(0);
const [menuDismissed, setMenuDismissed] = React.useState(false);
/** Set when the server reports the agent loop hit its iteration cap.
Surfaced so the user knows the answer may be incomplete rather than
mistaking a quiet stream end for a complete one. */
@ -78,6 +97,25 @@ export function ChatView() {
const q = question.trim();
if (!q || busy) return;
// Slash commands expand client-side. /help never calls the model.
const expanded = expandSlashCommand(q);
if (expanded?.kind === "error") {
setSlashError(expanded.message);
return;
}
setSlashError(null);
if (expanded?.kind === "help") {
setInput("");
setTurns((prev) => [
...prev,
{ role: "user", text: q, local: true },
{ role: "assistant", text: expanded.text, tools: [], charts: [], local: true },
]);
return;
}
const display = expanded?.kind === "prompt" ? expanded.display : q;
const prompt = expanded?.kind === "prompt" ? expanded.prompt : undefined;
setError(null);
setBusy(true);
setStoppedOnLimit(false);
@ -90,18 +128,21 @@ export function ChatView() {
...turns
.filter(
(t): t is Extract<Turn, { role: "user" | "assistant" }> =>
t.role !== "upload",
t.role !== "upload" && !t.local,
)
.map((t) => ({ role: t.role, text: t.text })),
.map((t) => ({
role: t.role,
text: t.role === "user" ? t.prompt ?? t.text : t.text,
})),
{
role: "user" as const,
text: q,
text: prompt ?? q,
...(images.length > 0 ? { images } : {}),
},
];
setTurns((prev) => [
...prev,
{ role: "user", text: q },
{ role: "user", text: display, ...(prompt ? { prompt } : {}) },
{ role: "assistant", text: "", tools: [], charts: [] },
]);
@ -199,6 +240,24 @@ export function ChatView() {
const empty = turns.length === 0;
/** Put text in the input and leave the cursor at its end. */
const fillInput = React.useCallback((text: string) => {
setInput(text);
setSlashError(null);
setMenuDismissed(false);
setMenuIndex(0);
requestAnimationFrame(() => {
const el = inputRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(text.length, text.length);
});
}, []);
const menu = menuDismissed ? [] : matchCommands(input);
const menuOpen = menu.length > 0 && input.trimStart().startsWith("/");
const activeIndex = Math.min(menuIndex, Math.max(0, menu.length - 1));
const ACCEPT = "application/pdf,image/jpeg,image/png,image/webp";
const MAX_BYTES = 20 * 1024 * 1024;
const fileRef = React.useRef<HTMLInputElement>(null);
@ -344,16 +403,28 @@ export function ChatView() {
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{SUGGESTIONS.map((s) => (
{COMMAND_CHIPS.map((c) => (
<button
key={s}
onClick={() => send(s)}
key={c.name}
onClick={() => fillInput(completion(c))}
className="group rounded-lg border border-border bg-card px-3.5 py-3 text-left text-sm transition-colors hover:border-[#F2B165]/50 hover:bg-[#F2B165]/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#F2B165]/60"
>
<span className="text-foreground">{s}</span>
<span className="font-mono text-[13px] text-foreground">/{c.name}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{c.summary}</span>
</button>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Type <span className="font-mono">/</span> for every shortcut, e.g.{" "}
<button
type="button"
onClick={() => fillInput("/site ")}
className="font-mono text-[#E08A3C] underline-offset-2 hover:underline"
>
/site The Hills
</button>
, or just ask a question.
</p>
</div>
)}
@ -477,8 +548,39 @@ export function ChatView() {
e.preventDefault();
send(input);
}}
className="mx-auto flex w-full max-w-3xl items-end gap-2 rounded-xl border border-border bg-card p-2 focus-within:border-[#F2B165]/50"
className="relative mx-auto flex w-full max-w-3xl items-end gap-2 rounded-xl border border-border bg-card p-2 focus-within:border-[#F2B165]/50"
>
{menuOpen && (
<div
id="slash-menu"
role="listbox"
aria-label="Shortcuts"
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-72 overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg"
>
{menu.map((c, i) => (
<button
key={c.name}
id={`slash-${c.name}`}
type="button"
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setMenuIndex(i)}
onClick={() => fillInput(completion(c))}
className={cn(
"flex w-full items-baseline gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm",
i === activeIndex ? "bg-[#F2B165]/15" : "hover:bg-muted/60",
)}
>
<span className="font-mono text-[13px] text-foreground">/{c.name}</span>
{argHint(c) && (
<span className="font-mono text-[11px] text-muted-foreground">{argHint(c)}</span>
)}
<span className="ml-auto truncate pl-3 text-xs text-muted-foreground">{c.summary}</span>
</button>
))}
</div>
)}
<input
ref={fileRef}
type="file"
@ -523,8 +625,41 @@ export function ChatView() {
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={(e) => {
setInput(e.target.value);
setSlashError(null);
setMenuDismissed(false);
setMenuIndex(0);
}}
role="combobox"
aria-expanded={menuOpen}
aria-controls="slash-menu"
aria-autocomplete="list"
aria-activedescendant={menuOpen ? `slash-${menu[activeIndex]?.name}` : undefined}
onKeyDown={(e) => {
if (menuOpen) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const step = e.key === "ArrowDown" ? 1 : -1;
setMenuIndex((activeIndex + step + menu.length) % menu.length);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMenuDismissed(true);
return;
}
const picked = menu[activeIndex];
const typed = input.trim();
// Tab always completes. Enter completes a partial name; on a
// complete command it falls through and sends.
const partial = typed !== `/${picked.name}` && !/\s/.test(typed);
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && partial)) {
e.preventDefault();
fillInput(completion(picked));
return;
}
}
// Enter sends; Shift+Enter makes a new line. While a
// request is in flight the user can still draft the next
// question — Enter is just ignored, the button stays
@ -535,7 +670,7 @@ export function ChatView() {
}
}}
rows={1}
placeholder="Ask about sites, fleet, plant or orders…"
placeholder="Ask about sites, fleet, plant or orders — or type / for reports"
className="max-h-40 flex-1 resize-none bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground"
/>
<button
@ -547,6 +682,11 @@ export function ChatView() {
<ArrowUp className={cn("h-4 w-4", busy && "animate-pulse motion-reduce:animate-none")} />
</button>
</form>
{slashError && (
<p role="alert" className="mx-auto mt-2 max-w-3xl text-xs text-red-700 dark:text-red-400">
{slashError}
</p>
)}
<p className="mx-auto mt-2 max-w-3xl text-[11px] text-muted-foreground">
Reads live PocketBase data. Figures come from the dashboard&apos;s own
calculations — the assistant never does its own arithmetic.

View file

@ -9,10 +9,6 @@
import { z } from "zod";
import {
computeVehicleAttention,
computeSiteSpend,
computeSiteProjection,
resolveSiteStartDate,
parseDurationMonths,
buildMonthlyIncomeVsExpenses,
buildWeeklyIncomeVsExpenses,
computeDeliveryNoteValue,

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { SLASH_COMMANDS, expandSlashCommand, matchCommands } from "./slash-commands";
describe("slash commands", () => {
it("is not a command without a leading slash", () => {
expect(expandSlashCommand("how is the hills")).toBeNull();
});
it("expands /site with its argument and names the tool", () => {
const r = expandSlashCommand("/site Hills");
expect(r).toMatchObject({ kind: "prompt", display: "/site Hills" });
expect(r && r.kind === "prompt" && r.prompt).toMatch(/site_report/);
expect(r && r.kind === "prompt" && r.prompt).toMatch(/Hills/);
});
it("requires a site for /site", () => {
expect(expandSlashCommand("/site")).toEqual({ kind: "error", message: "Which site? e.g. /site The Hills" });
});
it("/findings asks for all three reports and the My read section", () => {
const r = expandSlashCommand("/findings");
const p = r && r.kind === "prompt" ? r.prompt : "";
for (const t of ["portfolio_report", "problem_report", "improvement_report", "My read"]) expect(p).toContain(t);
});
it("/help is answered locally", () => {
expect(expandSlashCommand("/help")?.kind).toBe("help");
});
it("unknown commands say so", () => {
expect(expandSlashCommand("/nope")).toEqual({ kind: "error", message: "Unknown command /nope — type /help" });
});
it("menu filters by prefix", () => {
expect(matchCommands("/p").map((c) => c.name)).toEqual(["portfolio", "problems", "plant", "purchasing"]);
});
// Beyond the plan's contract tests:
it("keeps the contract order", () => {
expect(SLASH_COMMANDS.map((c) => c.name)).toEqual([
"site", "portfolio", "problems", "improvements", "findings",
"fuel", "plant", "workforce", "purchasing", "today", "help",
]);
});
it("scopes optional-site commands only when a site is given", () => {
const all = expandSlashCommand("/problems");
const one = expandSlashCommand("/problems Kya Sands ");
expect(all && all.kind === "prompt" && all.prompt).not.toMatch(/"/);
expect(one).toMatchObject({ kind: "prompt", display: "/problems Kya Sands" });
expect(one && one.kind === "prompt" && one.prompt).toContain('site "Kya Sands"');
});
it("ignores case in the command name and rejects arguments where none are taken", () => {
expect(expandSlashCommand("/PORTFOLIO")?.kind).toBe("prompt");
expect(expandSlashCommand("/today Hills")).toEqual({
kind: "error",
message: "/today takes no site — it covers every site.",
});
});
it("/help lists every command", () => {
const r = expandSlashCommand("/help");
const text = r && r.kind === "help" ? r.text : "";
for (const c of SLASH_COMMANDS) expect(text).toContain(`/${c.name}`);
});
it("names the tools each report needs", () => {
const prompt = (s: string) => {
const r = expandSlashCommand(s);
return r && r.kind === "prompt" ? r.prompt : "";
};
expect(prompt("/today")).toMatch(/attendance_today.*problem_report.*rain_outlook.*delivery_note_queue/s);
expect(prompt("/plant")).toContain("plant_equipment_summary");
expect(prompt("/workforce")).toContain("attendance_today");
expect(prompt("/purchasing")).toContain("supplier_balances");
expect(prompt("/fuel")).toContain("vehicles_needing_attention");
expect(prompt("/improvements")).toContain("improvement_report");
});
it("the bare slash lists every command and a partial name narrows it", () => {
expect(matchCommands("/")).toHaveLength(SLASH_COMMANDS.length);
expect(matchCommands("/site Hills").map((c) => c.name)).toEqual(["site"]);
expect(matchCommands("hello")).toEqual([]);
});
});

View file

@ -0,0 +1,167 @@
/**
* `/` shortcuts for Ask's base reports. Pure, client-side.
*
* A command expands into a precise prompt that names the tools to call,
* so "/site Hills" always produces the same report rather than whatever
* the model makes of a short phrase. /help is answered locally with no
* model call. The order of SLASH_COMMANDS is the menu order.
*/
export interface SlashCommand {
/** "site" */
name: string;
args: "none" | "optional-site" | "required-site";
/** Shown in the menu. */
summary: string;
prompt: (arg: string) => string;
}
export type Expanded =
| { kind: "prompt"; display: string; prompt: string }
| { kind: "help"; text: string }
| { kind: "error"; message: string };
const forSite = (arg: string) => (arg ? ` for "${arg}"` : " across all sites");
const withSite = (arg: string) => (arg ? ` with site "${arg}"` : "");
export const SLASH_COMMANDS: SlashCommand[] = [
{
name: "site",
args: "required-site",
summary: "Site report: KPIs, burn, income vs expenses, fuel, payroll, POs, attention",
prompt: (s) =>
`Produce a site report for "${s}". Call site_report for it, then problem_report for the same site. Lay it out as a report and place every chart.`,
},
{
name: "portfolio",
args: "none",
summary: "All sites: contract vs spent vs projected, burn vs elapsed, at-risk sites",
prompt: () =>
"Produce a portfolio report across all sites. Call portfolio_report. Lead with the sites at risk, show every site's status and projected profit in a table, and place every chart.",
},
{
name: "problems",
args: "optional-site",
summary: "Everything wrong right now, ranked by severity",
prompt: (s) =>
`Produce a problem report${forSite(s)}. Call problem_report${withSite(s)}. Group findings by severity, highest first, and link each one.`,
},
{
name: "improvements",
args: "optional-site",
summary: "Room for improvement: data gaps and setup fixes",
prompt: (s) =>
`Produce a room-for-improvement report${forSite(s)}. Call improvement_report${withSite(s)}. Group findings by severity, link each one, then write "My read" and "What I'd do next" as the prompt rules describe.`,
},
{
name: "findings",
args: "none",
summary: "Ask's own findings: portfolio + problems + improvements, with My read",
prompt: () =>
`Produce your own findings report. Call portfolio_report, problem_report and improvement_report. Then write "My read" and "What I'd do next" as the prompt rules describe.`,
},
{
name: "fuel",
args: "optional-site",
summary: "Fleet fuel: litres, spend, alerts, wrong-unit limits",
prompt: (s) =>
s
? `Produce a fuel report for "${s}". Call site_report for "${s}" (its fuel KPI and daily fuel chart) and vehicles_needing_attention, and report only that site's vehicles. Keep fuel alerts separate from fuel limits set in the wrong unit, and link each vehicle.`
: "Produce a fleet fuel report across all sites. Call get_fuel_summary and vehicles_needing_attention. Keep fuel alerts separate from fuel limits set in the wrong unit, and link each vehicle.",
},
{
name: "plant",
args: "optional-site",
summary: "Equipment hours and value (guarded meter diffs), rejected readings",
prompt: (s) =>
`Produce a plant report${forSite(s)}. Call plant_equipment_summary${withSite(s)}. Show the totals, the items with the most value and hours, call out rejected hour-meter readings and items with no cost per hour, and place every chart.`,
},
{
name: "workforce",
args: "optional-site",
summary: "Hours, cost, attendance today, missed scan-outs",
prompt: (s) =>
`Produce a workforce report${forSite(s)}. Call top_workers${withSite(s)} and attendance_today${withSite(s)}. Show who is on site, who has not arrived, missed scan-outs, and the top workers by hours with their cost.`,
},
{
name: "purchasing",
args: "optional-site",
summary: "POs, fulfilment, DN queue, supplier balances",
prompt: (s) =>
`Produce a purchasing report${forSite(s)}. Call list_purchase_orders${withSite(s)}, problem_report${withSite(s)} (for overdue and unverified orders), delivery_note_queue and supplier_balances. Lead with what is overdue or stuck, then what is owed to whom.`,
},
{
name: "today",
args: "none",
summary: "Daily brief: attendance, alerts, rain outlook, DN queue, imports",
prompt: () =>
"Daily brief for today: call attendance_today, problem_report, rain_outlook and delivery_note_queue. Keep it to one screen.",
},
{
name: "help",
args: "none",
summary: "List these commands",
prompt: () => "",
},
];
const ARG_HINT: Record<SlashCommand["args"], string> = {
none: "",
"optional-site": "[site]",
"required-site": "<site>",
};
export function argHint(c: SlashCommand): string {
return ARG_HINT[c.args];
}
function helpText(): string {
const lines = SLASH_COMMANDS.map(
(c) => `- **/${c.name}${argHint(c) ? ` ${argHint(c)}` : ""}** — ${c.summary}`,
);
return [
"**Shortcuts**",
"",
...lines,
"",
"`[site]` is optional; `<site>` is required. A site can be its name or project code, e.g. `/site The Hills` or `/problems C26/09`.",
].join("\n");
}
/** Split "/name rest of it " into ["name", "rest of it"]. */
function parse(input: string): { name: string; arg: string } | null {
const t = input.trim();
if (!t.startsWith("/")) return null;
const m = t.slice(1).match(/^(\S*)\s*([\s\S]*)$/);
return { name: (m?.[1] ?? "").toLowerCase(), arg: (m?.[2] ?? "").replace(/\s+/g, " ").trim() };
}
/** null = not a command (send as a normal question). */
export function expandSlashCommand(input: string): Expanded | null {
const p = parse(input);
if (!p) return null;
const cmd = SLASH_COMMANDS.find((c) => c.name === p.name);
if (!cmd) return { kind: "error", message: `Unknown command /${p.name} — type /help` };
if (cmd.name === "help") return { kind: "help", text: helpText() };
if (cmd.args === "required-site" && !p.arg) {
return { kind: "error", message: `Which site? e.g. /${cmd.name} The Hills` };
}
if (cmd.args === "none" && p.arg) {
return { kind: "error", message: `/${cmd.name} takes no site — it covers every site.` };
}
return {
kind: "prompt",
display: p.arg ? `/${cmd.name} ${p.arg}` : `/${cmd.name}`,
prompt: cmd.prompt(p.arg),
};
}
/** Commands for the menu. While an argument is being typed, only that command. */
export function matchCommands(partial: string): SlashCommand[] {
const p = parse(partial);
if (!p) return [];
const typingArg = /^\/\S+\s/.test(partial.trimStart());
return typingArg
? SLASH_COMMANDS.filter((c) => c.name === p.name)
: SLASH_COMMANDS.filter((c) => c.name.startsWith(p.name));
}