);
+ if (entries.length === 0)
+ return (
+
+ {"{ }"}
+
+ );
+ return (
+
+ {entries.map(([k, v]) => {
+ const isContainer =
+ v !== null && typeof v === "object" && Object.keys(v).length > 0;
+ return (
+
+ );
+ })}
+
+ );
+ }
+ return {String(value)};
+};
+
+// Render an AiStep's content payload (string or JSON) in the same
+// monospace box style used everywhere else in the card.
+const ContentBox: React.FC<{ content: unknown }> = ({ content }) => {
+ if (content === undefined || content === null) return null;
+ if (typeof content === "string") {
+ return (
+
+ {content}
+
+ );
+ }
+ // Tool-call args (and any other structured payload) — render as
+ // recursive badges. A flat JSON dump is hard to scan once
+ // arguments grow nested; a key→value chip tree mirrors how the
+ // model actually thought about the call.
+ return (
+
+
+
+ );
+};
+
+function stepStateGlyph(state: AiStep["state"]) {
+ switch (state) {
+ case "complete":
+ return ;
+ case "failed":
+ return ;
+ case "cancelled":
+ return ;
+ case "pending-approval":
+ return ;
+ default:
+ return (
+
+ );
+ }
+}
+
+const StepRow: React.FC<{
+ accent: string;
+ isFirst: boolean;
+ isLast: boolean;
+ children: React.ReactNode;
+}> = ({ accent, isFirst, isLast, children }) => (
+
+
+ {/* Vertical connector line: spans the full row height so
+ consecutive rows visually join; clipped on the first /
+ last row so it doesn't hang past the outermost dots. */}
+
+
+
+
{children}
+
+);
+
+// Render a single reasoning / text step. Colored dot, terse header,
+// then the content payload in a mono box if present.
+const Step: React.FC<{
+ step: AiStep;
+ isFirst: boolean;
+ isLast: boolean;
+}> = ({ step, isFirst, isLast }) => {
+ const accent = STEP_TYPE_ACCENT[step.type] ?? STEP_TYPE_ACCENT.text;
+
+ const headerKind =
+ step.type === "reasoning" ? Reasoning : Text;
+
+ return (
+
+
+
+ {headerKind}
+
+ {step.label && (
+
+ {step.label}
+
+ )}
+ {stepStateGlyph(step.state)}
+
+ {step.content !== undefined && step.content !== null && (
+
+
+
+ )}
+ {step.truncated && (
+
+ output truncated
+
+ )}
+
+ );
+};
+
+// Render a tool-call together with its matching tool-result as a
+// single row -- one header (the tool name), then IN (call args) and
+// OUT (result) stacked below. Either side may be absent during the
+// in-between window after the call lands and before the result
+// arrives.
+const ToolPairStep: React.FC<{
+ call?: AiStep;
+ result?: AiStep;
+ isFirst: boolean;
+ isLast: boolean;
+}> = ({ call, result, isFirst, isLast }) => {
+ const accent = STEP_TYPE_ACCENT["tool-call"];
+ const primary = result ?? call;
+ const tool = primary?.tool ?? "tool";
+ const label = primary?.label;
+ const state = primary?.state ?? "running";
+ const truncated = call?.truncated || result?.truncated;
+
+ return (
+
+
+
+ Tool
+
+ {tool}
+ {label && (
+
+ {label}
+
+ )}
+ {stepStateGlyph(state)}
+
+ {call?.content !== undefined && call?.content !== null && (
+
+ )}
+ {result?.content !== undefined && result?.content !== null && (
+
+ )}
+ {truncated && (
+
+ output truncated
+
+ )}
+
+ );
+};
+
+type RenderItem =
+ | { kind: "single"; key: string; step: AiStep }
+ | {
+ kind: "tool-pair";
+ key: string;
+ call?: AiStep;
+ result?: AiStep;
+ approvalSid?: string;
+ };
+
+function buildRenderItems(steps: AiStep[]): RenderItem[] {
+ const items: RenderItem[] = [];
+ const paired = new Set();
+ for (let i = 0; i < steps.length; i++) {
+ if (paired.has(i)) continue;
+ const s = steps[i];
+ if (s.type === "tool-call") {
+ let result: AiStep | undefined;
+ for (let j = i + 1; j < steps.length; j++) {
+ if (paired.has(j)) continue;
+ const t = steps[j];
+ if (t.type === "tool-result" && t.tool === s.tool) {
+ result = t;
+ paired.add(j);
+ break;
+ }
+ }
+ items.push({
+ kind: "tool-pair",
+ key: s.sid,
+ call: s,
+ result,
+ approvalSid:
+ s.state === "pending-approval"
+ ? s.sid
+ : result?.state === "pending-approval"
+ ? result.sid
+ : undefined,
+ });
+ } else if (s.type === "tool-result") {
+ // tool-result with no preceding tool-call: render standalone
+ items.push({
+ kind: "tool-pair",
+ key: s.sid,
+ result: s,
+ approvalSid: s.state === "pending-approval" ? s.sid : undefined,
+ });
+ } else {
+ items.push({ kind: "single", key: s.sid, step: s });
+ }
+ }
+ return items;
+}
+
+export const BotToolsCard: React.FC = ({ workflow }) => {
+ const { t } = useLingui();
+ const setCollapsed = useStore((s) => s.aiWorkflowSetCollapsed);
+ const dismiss = useStore((s) => s.aiWorkflowDismiss);
+ const sendAction = useStore((s) => s.aiSendAction);
+ // For "Responded in chat" — map the workflow's finalMsgid (an IRC
+ // msgid string) to the internal Message.id we use as the DOM key.
+ // Only takes a single string out of the store so we don't re-render
+ // the card on every unrelated message arrival.
+ const finalMessageInternalId = useStore((s) => {
+ if (!workflow.finalMsgid) return undefined;
+ for (const bucket of Object.values(s.messages)) {
+ for (const m of bucket) {
+ if (
+ m.serverId === workflow.serverId &&
+ m.msgid === workflow.finalMsgid
+ ) {
+ return m.id;
+ }
+ }
+ }
+ return undefined;
+ });
+
+ // Re-render at the 10-minute timeout boundary so a still-running
+ // workflow flips to "timed out" without a user interaction.
+ const [, forceTick] = useState(0);
+ useEffect(() => {
+ if (isTerminalState(workflow.state)) return;
+ const elapsed = Date.now() - workflow.startedAt;
+ const remaining = WORKFLOW_TIMEOUT_MS - elapsed;
+ if (remaining <= 0) return;
+ const id = setTimeout(() => forceTick((n) => n + 1), remaining + 50);
+ return () => clearTimeout(id);
+ }, [workflow.state, workflow.startedAt]);
+
+ const displayState = effectiveWorkflowState(workflow);
+ const isTerminal = isTerminalState(displayState);
+
+ const onToggle = () =>
+ setCollapsed(workflow.serverId, workflow.id, !workflow.collapsed);
+
+ const onCancel = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ sendAction(workflow.serverId, workflow.senderNick, {
+ msg: "action",
+ action: "cancel",
+ target: workflow.id,
+ });
+ };
+
+ const onApprove = (sid: string) => {
+ sendAction(workflow.serverId, workflow.senderNick, {
+ msg: "action",
+ action: "approve",
+ target: sid,
+ });
+ };
+
+ const onReject = (sid: string) => {
+ sendAction(workflow.serverId, workflow.senderNick, {
+ msg: "action",
+ action: "reject",
+ target: sid,
+ });
+ };
+
+ const pendingApprovals = workflow.steps.filter(
+ (s) => s.state === "pending-approval",
+ );
+
+ // Auto-dismiss countdown — once a workflow is terminal AND collapsed,
+ // start a 5s countdown. The user can re-expand or hover to pause.
+ // Expanding the card resets the timer entirely (they're reviewing the
+ // run); collapsing again restarts it.
+ const FADE_SECONDS = 5;
+ const [secondsLeft, setSecondsLeft] = useState(null);
+ const [paused, setPaused] = useState(false);
+ useEffect(() => {
+ if (!isTerminal || !workflow.collapsed) {
+ setSecondsLeft(null);
+ return;
+ }
+ setSecondsLeft(FADE_SECONDS);
+ }, [isTerminal, workflow.collapsed]);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: dismiss is a Zustand action with unstable ref
+ useEffect(() => {
+ if (secondsLeft === null || paused) return;
+ if (secondsLeft <= 0) {
+ dismiss(workflow.serverId, workflow.id);
+ return;
+ }
+ const t = setTimeout(() => setSecondsLeft((s) => (s ?? 0) - 1), 1000);
+ return () => clearTimeout(t);
+ }, [secondsLeft, paused, workflow.serverId, workflow.id]);
+
+ // Auto-scroll the expanded step list. We can't check "is the user
+ // at the bottom?" inside the content-update effect, because by the
+ // time the effect runs the new content has already grown
+ // scrollHeight and the old scrollTop no longer qualifies as
+ // bottom. Instead, track stickiness via the scroll event: whenever
+ // the user scrolls (or we programmatically scroll), recompute
+ // whether they're at the bottom. On content update, honour that
+ // flag. Reset to sticky on each fresh expansion so a freshly-
+ // opened card always lands on the latest content.
+ const bodyRef = useRef(null);
+ const isStickyToBottom = useRef(true);
+ const onBodyScroll = useCallback(() => {
+ const el = bodyRef.current;
+ if (!el) return;
+ const SCROLL_TOLERANCE = 24;
+ isStickyToBottom.current =
+ el.scrollHeight - (el.scrollTop + el.clientHeight) <= SCROLL_TOLERANCE;
+ }, []);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: track step count + updatedAt because step content updates don't change the array reference
+ useEffect(() => {
+ const el = bodyRef.current;
+ if (!el || workflow.collapsed) {
+ isStickyToBottom.current = true;
+ return;
+ }
+ if (isStickyToBottom.current) {
+ el.scrollTop = el.scrollHeight;
+ }
+ }, [workflow.collapsed, workflow.steps.length, workflow.updatedAt]);
+
+ // Linearly fade the card from full opacity to a barely-visible
+ // ghost over the entire countdown. Bottoms out at 0.15 so a
+ // re-hover (which pauses the timer) doesn't land on an invisible
+ // target.
+ const fadeOpacity =
+ secondsLeft !== null ? Math.max(0.15, secondsLeft / FADE_SECONDS) : 1;
+
+ return (
+ setPaused(true)}
+ onMouseLeave={() => setPaused(false)}
+ className="w-[680px] max-w-full bg-discord-dark-300/95 backdrop-blur-sm border border-discord-dark-400 rounded-lg shadow-xl overflow-hidden"
+ >
+ {/* Header — collapsed row.
with role=button because the
+ row holds Dismiss/Cancel
+ );
+};
+
+export default BotToolsCard;
diff --git a/src/components/ui/BotToolsHistoryButton.tsx b/src/components/ui/BotToolsHistoryButton.tsx
new file mode 100644
index 00000000..b723ba61
--- /dev/null
+++ b/src/components/ui/BotToolsHistoryButton.tsx
@@ -0,0 +1,278 @@
+import { Trans, useLingui } from "@lingui/react/macro";
+import type React from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ FaCheck,
+ FaProjectDiagram,
+ FaSpinner,
+ FaTimesCircle,
+} from "react-icons/fa";
+import {
+ countableSteps,
+ effectiveWorkflowState,
+ isStale,
+} from "../../lib/botTools";
+import type { AiWorkflow } from "../../store";
+import useStore from "../../store";
+import LoadingSpinner from "./LoadingSpinner";
+
+interface BotToolsHistoryButtonProps {
+ serverId: string;
+ channel: string | null;
+}
+
+const ACTIVE_STATES: ReadonlySet
= new Set([
+ "start",
+ "reasoning",
+ "running",
+]);
+
+function isActive(w: AiWorkflow): boolean {
+ if (isStale(w)) return false;
+ return ACTIVE_STATES.has(w.state);
+}
+
+function stateGlyph(state: AiWorkflow["state"]) {
+ switch (state) {
+ case "complete":
+ return ;
+ case "failed":
+ case "cancelled":
+ return ;
+ default:
+ return (
+
+ );
+ }
+}
+
+function fmtAgo(ts: number): string {
+ const s = Math.max(1, Math.round((Date.now() - ts) / 1000));
+ if (s < 60) return `${s}s`;
+ const m = Math.round(s / 60);
+ if (m < 60) return `${m}m`;
+ const h = Math.round(m / 60);
+ if (h < 24) return `${h}h`;
+ return `${Math.round(h / 24)}d`;
+}
+
+// Workflow history button + popover for the chat header.
+//
+// Now two-level: top tier lists the bots that have run a workflow in
+// the active channel (most-recently-active first); hovering or clicking
+// a bot opens a second tier of that bot's workflows, latest-first.
+// When at least one workflow is in flight the icon carries the shared
+// LoadingSpinner badge so the user can see something is happening
+// without the tray of cards we used to pop up automatically.
+export const BotToolsHistoryButton: React.FC = ({
+ serverId,
+ channel,
+}) => {
+ const { t } = useLingui();
+ const reopen = useStore((s) => s.aiWorkflowReopen);
+ const serverWorkflows = useStore((s) => s.aiWorkflows[serverId]);
+
+ const workflows = useMemo(() => {
+ if (!serverWorkflows || !channel) return [];
+ return Object.values(serverWorkflows)
+ .filter((w) => w.channel === channel)
+ .sort((a, b) => b.startedAt - a.startedAt);
+ }, [serverWorkflows, channel]);
+
+ // Group by sender nick. Each bucket sorted latest-first so the
+ // submenu reads top -> bottom newest-to-oldest, matching the rest
+ // of the chat surface.
+ const byBot = useMemo(() => {
+ const map = new Map();
+ for (const w of workflows) {
+ const key = w.senderNick;
+ const list = map.get(key) ?? [];
+ list.push(w);
+ map.set(key, list);
+ }
+ // Sort the bots by their latest workflow's start time.
+ return Array.from(map.entries())
+ .map(([nick, list]) => ({
+ nick,
+ list,
+ latestAt: list[0]?.startedAt ?? 0,
+ activeCount: list.filter(isActive).length,
+ }))
+ .sort((a, b) => b.latestAt - a.latestAt);
+ }, [workflows]);
+
+ const activeTotal = useMemo(
+ () => workflows.filter(isActive).length,
+ [workflows],
+ );
+
+ const [open, setOpen] = useState(false);
+ const [expandedBot, setExpandedBot] = useState(null);
+ const rootRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) {
+ setExpandedBot(null);
+ return;
+ }
+ const onClick = (e: MouseEvent) => {
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("mousedown", onClick);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", onClick);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ if (workflows.length === 0) return null;
+
+ return (
+
+
setOpen((o) => !o)}
+ title={
+ activeTotal > 0
+ ? t`Workflow history (${workflows.length}, ${activeTotal} active)`
+ : t`Workflow history (${workflows.length})`
+ }
+ aria-expanded={open}
+ >
+
+
+ {workflows.length}
+
+ {activeTotal > 0 && (
+
+
+
+ )}
+
+ {open && (
+
+
+
+ Workflow history
+
+ {workflows.length}
+
+
+ {byBot.map(({ nick, list, activeCount }) => {
+ const isExpanded = expandedBot === nick;
+ return (
+ -
+
+ setExpandedBot((prev) => (prev === nick ? null : nick))
+ }
+ className="w-full flex items-center gap-2.5 px-3 py-2.5 text-left hover:bg-discord-dark-400/60 transition-colors"
+ aria-expanded={isExpanded}
+ >
+
+ {activeCount > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {nick}
+
+
+ {fmtAgo(list[0].startedAt)}
+
+
+
+ {list.length} workflow(s)
+ {activeCount > 0 && (
+
+ {" · "}
+ {activeCount} active
+
+ )}
+
+
+
+ ▸
+
+
+ {isExpanded && (
+
+ {list.map((w) => (
+ -
+ {
+ reopen(w.serverId, w.id);
+ setOpen(false);
+ }}
+ className="w-full flex items-start gap-2.5 pl-9 pr-3 py-2 text-left hover:bg-discord-dark-400/60 transition-colors"
+ >
+
+ {stateGlyph(effectiveWorkflowState(w))}
+
+
+
+
+ {w.name ?? t`Workflow`}
+
+
+ {fmtAgo(w.startedAt)}
+
+
+
+ {countableSteps(w.steps)} step(s)
+ {(() => {
+ const eff = effectiveWorkflowState(w);
+ if (eff === "running" || eff === "start")
+ return null;
+ let label: string;
+ if (isStale(w)) label = t`timed out`;
+ else if (eff === "complete")
+ label = t`complete`;
+ else if (eff === "failed") label = t`failed`;
+ else if (eff === "cancelled")
+ label = t`cancelled`;
+ else label = eff;
+ return (
+
+ {" · "}
+ {label}
+
+ );
+ })()}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ );
+};
+
+export default BotToolsHistoryButton;
diff --git a/src/components/ui/BotToolsTray.tsx b/src/components/ui/BotToolsTray.tsx
new file mode 100644
index 00000000..1a6b5fa6
--- /dev/null
+++ b/src/components/ui/BotToolsTray.tsx
@@ -0,0 +1,47 @@
+import type React from "react";
+import { useMemo } from "react";
+import useStore from "../../store";
+import { BotToolsCard } from "./BotToolsCard";
+
+interface BotToolsTrayProps {
+ serverId: string | null;
+ channel: string | null;
+}
+
+// Tray now only renders cards the user *explicitly* re-opened from the
+// history popover or the "view workflow" affordance next to a finished
+// PRIVMSG. Live workflows no longer auto-pop a card; the chat-header
+// workflow icon shows a spinner badge while one is in flight and lets
+// the user open the card when they want it. This keeps the right edge
+// of the chat area uncluttered when many bots are working at once.
+export const BotToolsTray: React.FC = ({
+ serverId,
+ channel,
+}) => {
+ const serverWorkflows = useStore((s) =>
+ serverId ? s.aiWorkflows[serverId] : undefined,
+ );
+
+ const visible = useMemo(() => {
+ if (!serverWorkflows || !channel) return [];
+ return Object.values(serverWorkflows)
+ .filter(
+ (w) => w.userOpened === true && !w.dismissed && w.channel === channel,
+ )
+ .sort((a, b) => b.startedAt - a.startedAt);
+ }, [serverWorkflows, channel]);
+
+ if (visible.length === 0) return null;
+
+ return (
+
+ {visible.map((w) => (
+
+
+
+ ))}
+
+ );
+};
+
+export default BotToolsTray;
diff --git a/src/components/ui/BotsModal.tsx b/src/components/ui/BotsModal.tsx
new file mode 100644
index 00000000..0e8b0bb5
--- /dev/null
+++ b/src/components/ui/BotsModal.tsx
@@ -0,0 +1,661 @@
+// Bot management modal — directory view of the obby.world/channel-bots
+// cap state. Mirrors UserSettings' layout: desktop = backdrop + centered
+// card with a left sidebar (filterable bot list) and right content pane
+// (selected bot's detail); mobile = full-screen portal with a two-view
+// drill-in (list → detail, back button to return).
+//
+// Bot lifecycle pushes from the IRCd land in server.bots; this modal is
+// a pure read of that map plus a few send-and-forget /PUSHBOT subcommand
+// buttons for IRCops.
+
+import { t } from "@lingui/core/macro";
+import { Trans, useLingui } from "@lingui/react/macro";
+import type React from "react";
+import { useEffect, useMemo, useState } from "react";
+import { createPortal } from "react-dom";
+import { FaChevronLeft, FaCircle, FaRobot, FaTimes } from "react-icons/fa";
+import { useMediaQuery } from "../../hooks/useMediaQuery";
+import { useModalBehavior } from "../../hooks/useModalBehavior";
+import ircClient from "../../lib/ircClient";
+import useStore from "../../store";
+import type { BotCommand, PushBotInfo } from "../../types";
+import LoadingSpinner from "./LoadingSpinner";
+
+interface BotsModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ serverId: string;
+ /** Invoked when the user clicks one of the bot's slash commands.
+ * ChatArea opens the slash-command param modal in response. */
+ onPickCommand?: (botNick: string, command: BotCommand) => void;
+ /** Nick to focus when the modal opens. Used by deep-links from
+ * the member-list bot popover ("Show in Bots Menu") so the
+ * selected bot's command list is the first thing visible. */
+ preselectNick?: string | null;
+}
+
+type FilterMode = "all" | "server" | "channel";
+
+// Module-scope colour classes — these never change per locale so they
+// stay literals. Labels and tooltips are built at render time via
+// useStatusBadge / useScopeBadge / useFilterLabels so the t macro fires
+// after i18n.activate has run.
+
+const STATUS_CLS: Record = {
+ active: "bg-emerald-700/40 text-emerald-300 border border-emerald-600/50",
+ pending: "bg-amber-700/40 text-amber-300 border border-amber-600/50",
+ suspended: "bg-red-700/40 text-red-300 border border-red-600/50",
+ deleted:
+ "bg-discord-dark-400 text-discord-text-muted border border-discord-dark-500",
+};
+
+const SCOPE_CLS: Record = {
+ server:
+ "bg-discord-primary/30 text-discord-primary border border-discord-primary/40",
+ channel: "bg-amber-700/30 text-amber-300 border border-amber-600/40",
+};
+
+function useStatusBadge(): Record<
+ PushBotInfo["status"],
+ { label: string; cls: string }
+> {
+ const { t } = useLingui();
+ return {
+ active: { label: t`active`, cls: STATUS_CLS.active },
+ pending: { label: t`pending`, cls: STATUS_CLS.pending },
+ suspended: { label: t`suspended`, cls: STATUS_CLS.suspended },
+ deleted: { label: t`deleted`, cls: STATUS_CLS.deleted },
+ };
+}
+
+function useScopeBadge(): Record<
+ PushBotInfo["scope"],
+ { label: string; title: string; cls: string }
+> {
+ const { t } = useLingui();
+ return {
+ server: {
+ label: t`server`,
+ title: t`Server-wide bot — reachable from any channel`,
+ cls: SCOPE_CLS.server,
+ },
+ channel: {
+ label: t`channel`,
+ title: t`Channel bot — only in joined channels`,
+ cls: SCOPE_CLS.channel,
+ },
+ };
+}
+
+function useFilterLabels(): Record {
+ const { t } = useLingui();
+ return {
+ all: t`All`,
+ server: t`Server-wide`,
+ channel: t`Channel`,
+ };
+}
+
+// ── child components ──────────────────────────────────────────────────
+
+interface BotRowProps {
+ bot: PushBotInfo;
+ selected: boolean;
+ loading?: boolean;
+ onSelect: () => void;
+}
+
+const BotRow: React.FC = ({
+ bot,
+ selected,
+ loading,
+ onSelect,
+}) => {
+ const STATUS_BADGE = useStatusBadge();
+ const SCOPE_BADGE = useScopeBadge();
+ return (
+
+
+
+ {bot.nick}
+ {loading && (
+
+
+
+ )}
+
+ {SCOPE_BADGE[bot.scope].label}
+
+
+ {bot.status !== "active" && (
+
+
+ {STATUS_BADGE[bot.status].label}
+
+
+ )}
+ {bot.realname && (
+
+ {bot.realname}
+
+ )}
+
+ );
+};
+
+interface BotDetailProps {
+ bot: PushBotInfo;
+ isOper: boolean;
+ loading?: boolean;
+ onAction: (subcmd: string) => void;
+ onPickCommand?: (command: BotCommand) => void;
+}
+
+const BotDetail: React.FC = ({
+ bot,
+ isOper,
+ loading,
+ onAction,
+ onPickCommand,
+}) => {
+ const STATUS_BADGE = useStatusBadge();
+ const SCOPE_BADGE = useScopeBadge();
+ return (
+
+
+
{bot.nick}
+
+ {SCOPE_BADGE[bot.scope].label}
+
+
+ {STATUS_BADGE[bot.status].label}
+
+
+
+ {bot.online ? t`gateway online` : t`offline`}
+
+
+ {bot.realname && (
+
{bot.realname}
+ )}
+
+
+ -
+ Transport
+
+ - {bot.transport}
+ -
+ Source
+
+ -
+ {bot.from_config ? t`config-defined` : t`self-registered`}
+
+ {bot.scope === "channel" && (
+ <>
+ -
+ Channels
+
+ -
+ {bot.channels.length ? bot.channels.join(", ") : "—"}
+
+ >
+ )}
+ {bot.webhook_url !== undefined && (
+ <>
+ -
+ Webhook
+
+ -
+ {bot.webhook_url || "—"}
+ {bot.webhook_suspended && (
+
+ (suspended)
+
+ )}
+
+ >
+ )}
+
+
+
+
+ Slash commands
+
+ {bot.commands.length === 0 ? (
+
+ {loading ? (
+ <>
+
+ Commands are loading…
+ >
+ ) : (
+ Bot hasn't registered any slash commands yet.
+ )}
+
+ ) : (
+
+ {bot.commands.map((cmd) => {
+ const body = (
+ <>
+
+ /{cmd.name}
+ {(cmd.options ?? []).map((o) => (
+
+ {o.required ? `<${o.name}>` : `[${o.name}]`}
+
+ ))}
+
+ {cmd.description && (
+
+ {cmd.description}
+
+ )}
+ >
+ );
+ return onPickCommand ? (
+ -
+ onPickCommand(cmd)}
+ className="w-full text-left bg-discord-dark-400 hover:bg-discord-dark-500 rounded px-3 py-2 transition-colors"
+ >
+ {body}
+
+
+ ) : (
+ -
+ {body}
+
+ );
+ })}
+
+ )}
+
+
+ {isOper && !bot.from_config && (
+
+
+ Operator actions
+
+
+ {bot.status === "pending" && (
+ onAction("APPROVE")}
+ className="bg-emerald-700 hover:bg-emerald-600 text-white px-3 py-1.5 rounded text-sm transition-colors"
+ >
+ Approve
+
+ )}
+ {bot.status === "active" && (
+ onAction("SUSPEND")}
+ className="bg-amber-700 hover:bg-amber-600 text-white px-3 py-1.5 rounded text-sm transition-colors"
+ >
+ Suspend
+
+ )}
+ {bot.status === "suspended" && (
+ onAction("UNSUSPEND")}
+ className="bg-emerald-700 hover:bg-emerald-600 text-white px-3 py-1.5 rounded text-sm transition-colors"
+ >
+ Unsuspend
+
+ )}
+ {
+ if (
+ window.confirm(
+ t`Delete bot ${bot.nick}? This soft-deletes the database row; reuse the nick later only after a /REHASH.`,
+ )
+ ) {
+ onAction("DELETE");
+ }
+ }}
+ className="bg-red-700 hover:bg-red-600 text-white px-3 py-1.5 rounded text-sm transition-colors"
+ >
+ Delete
+
+
+
+ )}
+ {isOper && bot.from_config && (
+
+
+ Config-defined bot. Edit obbyircd.conf and /REHASH to change state.
+
+
+ )}
+
+ );
+};
+
+// ── main modal ────────────────────────────────────────────────────────
+
+const BotsModal: React.FC = ({
+ isOpen,
+ onClose,
+ serverId,
+ onPickCommand,
+ preselectNick,
+}) => {
+ const isMobile = useMediaQuery("(max-width: 768px)");
+ const server = useStore((s) => s.servers.find((srv) => srv.id === serverId));
+ const currentUser = useStore((s) => s.currentUser);
+ const FILTER_LABELS = useFilterLabels();
+ const [filter, setFilter] = useState("all");
+ const [query, setQuery] = useState("");
+ const [selectedNick, setSelectedNick] = useState(
+ preselectNick ?? null,
+ );
+ const [mobileView, setMobileView] = useState<"list" | "detail">(
+ preselectNick ? "detail" : "list",
+ );
+ useEffect(() => {
+ if (isOpen && preselectNick) {
+ setSelectedNick(preselectNick);
+ setMobileView("detail");
+ }
+ }, [isOpen, preselectNick]);
+
+ const { getBackdropProps, getContentProps } = useModalBehavior({
+ onClose,
+ isOpen,
+ });
+
+ const isOper = (() => {
+ const myNick = currentUser?.username;
+ if (!myNick || !server) return false;
+ const me = server.users.find((u) => u.username === myNick);
+ return !!me?.isIrcOp;
+ })();
+
+ const bots: PushBotInfo[] = useMemo(() => {
+ if (!server?.bots) return [];
+ // Reachability filter: server-scope bots are always shown
+ // (reachable from any context). Channel-scope bots only appear
+ // when the local user still shares a channel with them -- this
+ // matches the server-side discovery contract (server-scope on
+ // burst, channel-scope on LOCAL_JOIN of a shared channel) and
+ // hides stale entries left over after the user parts a channel.
+ const joinedChannels = new Set(
+ (server.channels ?? []).map((c) => c.name.toLowerCase()),
+ );
+ const reachable = (b: PushBotInfo) => {
+ // Opers manage bots they may not share a channel with; don't
+ // hide channel-scope entries from them.
+ if (isOper) return true;
+ if (b.scope === "server") return true;
+ if (!b.channels?.length) return false;
+ return b.channels.some((ch) => joinedChannels.has(ch.toLowerCase()));
+ };
+ const all = Object.values(server.bots);
+ return all
+ .filter(reachable)
+ .filter((b) => filter === "all" || b.scope === filter)
+ .filter((b) =>
+ query
+ ? b.nick.toLowerCase().includes(query.toLowerCase()) ||
+ (b.realname ?? "").toLowerCase().includes(query.toLowerCase())
+ : true,
+ )
+ .sort((a, b) => {
+ const sa = a.status === "active" ? 0 : a.status === "pending" ? 1 : 2;
+ const sb = b.status === "active" ? 0 : b.status === "pending" ? 1 : 2;
+ if (sa !== sb) return sa - sb;
+ return a.nick.localeCompare(b.nick);
+ });
+ }, [server?.bots, server?.channels, filter, query, isOper]);
+
+ const selected = selectedNick
+ ? server?.bots?.[selectedNick.toLowerCase()]
+ : undefined;
+
+ const send = (subcmd: string) => {
+ if (!isOper || !selected) return;
+ ircClient.sendRaw(serverId, `PUSHBOT ${subcmd} ${selected.nick}`);
+ if (subcmd === "DELETE") {
+ setSelectedNick(null);
+ if (isMobile) setMobileView("list");
+ }
+ };
+
+ const onPickBot = (nick: string) => {
+ setSelectedNick(nick);
+ if (isMobile) setMobileView("detail");
+ };
+
+ if (!isOpen) return null;
+
+ // ── list-pane content, shared between mobile and desktop ────────────
+ const listPane = (
+ <>
+
+
setQuery(e.target.value)}
+ placeholder={t`Search bots`}
+ className="w-full bg-discord-dark-400 rounded px-2 py-1.5 text-sm text-discord-text-normal placeholder:text-discord-text-muted border border-discord-dark-300 focus:outline-none focus:border-discord-primary"
+ />
+
+ {(["all", "server", "channel"] as FilterMode[]).map((f) => (
+
setFilter(f)}
+ className={`px-2 py-1 rounded transition-colors ${
+ filter === f
+ ? "bg-discord-primary text-white"
+ : "bg-discord-dark-400 text-discord-text-muted hover:text-white"
+ }`}
+ >
+ {FILTER_LABELS[f]}
+
+ ))}
+
+ {bots.length}
+
+
+
+
+ {bots.length === 0 ? (
+
+ No bots registered on this network yet.
+
+ ) : (
+ bots.map((b) => (
+
onPickBot(b.nick)}
+ />
+ ))
+ )}
+
+ >
+ );
+
+ const detailPane = selected ? (
+ {
+ onPickCommand(selected.nick, cmd);
+ onClose();
+ }
+ : undefined
+ }
+ />
+ ) : (
+
+
+
+ Select a bot on the left to see its commands and management actions.
+
+
+ );
+
+ // ── mobile: full-screen portal with two views ──────────────────────
+ if (isMobile) {
+ const portalTarget = document.getElementById("root") || document.body;
+ return createPortal(
+
+ {mobileView === "list" ? (
+ <>
+
+
+
+ Bots
+
+
+
+
+
+ {listPane}
+ >
+ ) : (
+ <>
+
+
+ setMobileView("list")}
+ className="p-1 rounded-lg hover:bg-discord-dark-400 text-discord-text-muted hover:text-white flex-shrink-0"
+ aria-label={t`Back`}
+ >
+
+
+
+ {selected?.nick ?? t`Bot`}
+
+
+
+
+
+
+
{detailPane}
+ >
+ )}
+
,
+ portalTarget,
+ );
+ }
+
+ // ── desktop: backdrop + centered card with sidebar + content ───────
+ const portalTarget = document.getElementById("root") || document.body;
+ return createPortal(
+
+
+ {/* Sidebar — list of bots */}
+
+
+
+
+ Bots
+
+
+ {listPane}
+
+
+ {/* Main content — bot detail */}
+
+
+
+ {selected ? selected.nick : t`Bots on this network`}
+
+
+
+
+
+
+
+
+
,
+ portalTarget,
+ );
+};
+
+export default BotsModal;
diff --git a/src/components/ui/SlashCommandParamModal.tsx b/src/components/ui/SlashCommandParamModal.tsx
new file mode 100644
index 00000000..a0d18c87
--- /dev/null
+++ b/src/components/ui/SlashCommandParamModal.tsx
@@ -0,0 +1,440 @@
+// Param-collection modal opened when a user selects a slash command
+// from the SlashCommandPopover and that command declares any
+// `options[]`. Renders one form field per option, typed by
+// `option.type`:
+//
+// string text input
+// int / number numeric input (step=1 for int, any for number)
+// bool checkbox
+// user combobox of channel members + DM partner
+// channel combobox of joined channels
+// date / time /
+// datetime native date/time picker
+// country select w/ flag + name; wire value is ISO-2 code
+// password masked text input
+// * with choices[] select of the bot-declared choices
+//
+// On submit the modal builds an options-map and calls sendBotCommand
+// directly -- bypassing the freeform-args parser in useMessageSending.
+
+import { Trans, t } from "@lingui/macro";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { sendBotCommand } from "../../hooks/useMessageSending";
+import { COUNTRIES, flagEmoji } from "../../lib/countries";
+import type {
+ BotCommand,
+ BotCommandOption,
+ Channel,
+ PrivateChat,
+} from "../../types";
+
+interface Props {
+ serverId: string;
+ botNick: string;
+ command: BotCommand;
+ channel: Channel | null;
+ privateChat: PrivateChat | null;
+ /** Members from the active channel so the "user" type can
+ * autocomplete against people who are reachable right now. */
+ channelMembers: string[];
+ /** Channel names the user has joined for the "channel" type. */
+ joinedChannels: string[];
+ onClose: () => void;
+}
+
+export const SlashCommandParamModal: React.FC = ({
+ serverId,
+ botNick,
+ command,
+ channel,
+ privateChat,
+ channelMembers,
+ joinedChannels,
+ onClose,
+}) => {
+ const opts = command.options ?? [];
+ const [values, setValues] = useState<
+ Record
+ >(() => {
+ const init: Record = {};
+ for (const o of opts) {
+ if (o.type === "bool") init[o.name] = false;
+ else if (o.type === "int" || o.type === "number") init[o.name] = "";
+ else init[o.name] = "";
+ }
+ return init;
+ });
+ const [error, setError] = useState(null);
+ const firstFieldRef = useRef(
+ null,
+ );
+
+ useEffect(() => {
+ firstFieldRef.current?.focus();
+ }, []);
+
+ // Allow Escape to dismiss; Enter on a non-textarea submits.
+ const onKey = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.stopPropagation();
+ onClose();
+ }
+ },
+ [onClose],
+ );
+ useEffect(() => {
+ document.addEventListener("keydown", onKey, true);
+ return () => document.removeEventListener("keydown", onKey, true);
+ }, [onKey]);
+
+ const setVal = (name: string, v: string | number | boolean) =>
+ setValues((prev) => ({ ...prev, [name]: v }));
+
+ const submit = (e: React.FormEvent) => {
+ e.preventDefault();
+ // Validate required + coerce numerics; drop empty optional fields
+ // so the bot sees them as absent rather than empty string.
+ const payload: Record = {};
+ for (const o of opts) {
+ const raw = values[o.name];
+ const empty =
+ raw === "" || raw === undefined || raw === null || raw === false;
+ if (o.required && empty && o.type !== "bool") {
+ setError(t`${o.name} is required.`);
+ return;
+ }
+ if (raw === "" || raw === undefined || raw === null) continue;
+ if (o.type === "int") {
+ const n = Number.parseInt(String(raw), 10);
+ if (Number.isNaN(n)) {
+ setError(t`${o.name} must be a whole number.`);
+ return;
+ }
+ payload[o.name] = n;
+ } else if (o.type === "number") {
+ const n = Number.parseFloat(String(raw));
+ if (Number.isNaN(n)) {
+ setError(t`${o.name} must be a number.`);
+ return;
+ }
+ payload[o.name] = n;
+ } else if (o.type === "bool") {
+ payload[o.name] = Boolean(raw);
+ } else {
+ payload[o.name] = String(raw);
+ }
+ }
+ sendBotCommand(serverId, channel, botNick, command, payload);
+ onClose();
+ };
+
+ return (
+
+ );
+};
+
+interface ParamFieldProps {
+ option: BotCommandOption;
+ value: string | number | boolean;
+ setValue: (v: string | number | boolean) => void;
+ channelMembers: string[];
+ joinedChannels: string[];
+ privateChatNick: string | undefined;
+ autoFocusRef?:
+ | React.RefObject
+ | undefined;
+}
+
+const BASE_INPUT =
+ "w-full px-3 py-2 rounded bg-discord-dark-400 text-white text-sm placeholder:text-discord-text-muted/60 focus:outline-none focus:ring-1 focus:ring-discord-primary";
+
+const ParamField: React.FC = ({
+ option,
+ value,
+ setValue,
+ channelMembers,
+ joinedChannels,
+ privateChatNick,
+ autoFocusRef,
+}) => {
+ // Bot-declared `choices` override the type renderer with a select.
+ const renderChoices = useMemo(() => {
+ if (!option.choices?.length) return null;
+ return (
+
+ );
+ }, [option.choices, value, setValue, autoFocusRef]);
+
+ let field: React.ReactNode;
+ if (renderChoices) {
+ field = renderChoices;
+ } else {
+ switch (option.type) {
+ case "bool":
+ field = (
+
+ );
+ break;
+ case "int":
+ field = (
+ }
+ type="number"
+ step="1"
+ value={String(value ?? "")}
+ onChange={(e) => setValue(e.target.value)}
+ className={BASE_INPUT}
+ />
+ );
+ break;
+ case "number":
+ field = (
+ }
+ type="number"
+ step="any"
+ value={String(value ?? "")}
+ onChange={(e) => setValue(e.target.value)}
+ className={BASE_INPUT}
+ />
+ );
+ break;
+ case "date":
+ field = (
+ }
+ type="date"
+ value={String(value ?? "")}
+ onChange={(e) => setValue(e.target.value)}
+ className={BASE_INPUT}
+ />
+ );
+ break;
+ case "time":
+ field = (
+ }
+ type="time"
+ value={String(value ?? "")}
+ onChange={(e) => setValue(e.target.value)}
+ className={BASE_INPUT}
+ />
+ );
+ break;
+ case "datetime":
+ field = (
+ }
+ type="datetime-local"
+ value={String(value ?? "")}
+ onChange={(e) => setValue(e.target.value)}
+ className={BASE_INPUT}
+ />
+ );
+ break;
+ case "password":
+ field = (
+ }
+ type="password"
+ value={String(value ?? "")}
+ onChange={(e) => setValue(e.target.value)}
+ className={BASE_INPUT}
+ autoComplete="new-password"
+ />
+ );
+ break;
+ case "country":
+ field = (
+
+ );
+ break;
+ case "user": {
+ const choices = Array.from(
+ new Set(
+ [...channelMembers, ...(privateChatNick ? [privateChatNick] : [])]
+ .filter(Boolean)
+ .sort((a, b) => a.localeCompare(b)),
+ ),
+ );
+ const listId = `param-users-${option.name}`;
+ field = (
+ <>
+ }
+ type="text"
+ value={String(value ?? "")}
+ onChange={(e) => setValue(e.target.value)}
+ className={BASE_INPUT}
+ placeholder={t`nick`}
+ list={listId}
+ autoComplete="off"
+ />
+
+ >
+ );
+ break;
+ }
+ case "channel": {
+ const listId = `param-channels-${option.name}`;
+ field = (
+ <>
+ }
+ type="text"
+ value={String(value ?? "")}
+ onChange={(e) => setValue(e.target.value)}
+ className={BASE_INPUT}
+ placeholder={t`#channel`}
+ list={listId}
+ autoComplete="off"
+ />
+
+ >
+ );
+ break;
+ }
+ default:
+ field = (
+ }
+ type="text"
+ value={String(value ?? "")}
+ onChange={(e) => setValue(e.target.value)}
+ className={BASE_INPUT}
+ />
+ );
+ }
+ }
+
+ return (
+
+
+ {field}
+ {option.description && option.type !== "bool" && (
+
+ {option.description}
+
+ )}
+
+ );
+};
+
+export default SlashCommandParamModal;
diff --git a/src/components/ui/SlashCommandPopover.tsx b/src/components/ui/SlashCommandPopover.tsx
index 42d28568..d91af25a 100644
--- a/src/components/ui/SlashCommandPopover.tsx
+++ b/src/components/ui/SlashCommandPopover.tsx
@@ -1,9 +1,19 @@
// Slash-command suggestion popover, anchored above the chat input.
//
-// Activates when the input value starts with "/" and the user is still
-// typing the command name (no space yet). Filters the server's
-// cmdsAvailable set (populated by the obsidianirc/cmdslist cap) by
-// prefix match.
+// Three sources feed into this:
+// * client → commands handled locally by the React app before they
+// touch the wire (e.g. /me, /msg, /nick). Defined in
+// src/lib/clientCommands.ts; rendered with a "client" badge.
+// * server → obsidianirc/cmdslist capability — the IRCd's set of
+// commands the user is currently permitted to invoke (e.g. /op,
+// /kick, /mode); rendered with a "server" badge.
+// * bot → draft/bot-cmds — per-bot schemas with descriptions,
+// options, scope. "channel-bot" or "server-bot" badge.
+//
+// Below the command name we show the description and a `` /
+// `[optional]` parameter signature. Once the user accepts a
+// suggestion and starts typing arguments, the popover yields to the
+// param-hint footer (see SlashParamHint).
//
// Keyboard:
// ArrowUp / ArrowDown -- cycle highlighted suggestion
@@ -12,15 +22,29 @@
//
// onSelect receives the bare command name (without the leading slash).
+import { Trans, useLingui } from "@lingui/react/macro";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
+import type { BotCommandOption } from "../../types";
+
+export type SlashSuggestionSource =
+ | { kind: "client" }
+ | { kind: "server" }
+ | { kind: "bot"; botNick: string; scope?: "channel" | "server" };
+
+export interface SlashSuggestion {
+ name: string;
+ description?: string;
+ options?: BotCommandOption[];
+ source: SlashSuggestionSource;
+}
interface SlashCommandPopoverProps {
isVisible: boolean;
inputValue: string;
- commands: string[];
+ commands: SlashSuggestion[];
inputElement?: HTMLInputElement | HTMLTextAreaElement | null;
- onSelect: (command: string) => void;
+ onSelect: (suggestion: SlashSuggestion) => void;
onClose: () => void;
}
@@ -40,6 +64,74 @@ export function getActiveSlashQuery(
return beforeCursor.slice(1).toLowerCase();
}
+/** Compact "name [optional]" rendering of an option list. */
+export function formatOptions(options: BotCommandOption[] | undefined): string {
+ if (!options || options.length === 0) return "";
+ return options
+ .map((o) => (o.required ? `<${o.name}>` : `[${o.name}]`))
+ .join(" ");
+}
+
+interface BadgeStyle {
+ label: string;
+ title: string;
+ className: string;
+}
+
+function badgeStyle(
+ source: SlashSuggestionSource,
+ t: ReturnType["t"],
+): BadgeStyle {
+ switch (source.kind) {
+ case "client":
+ return {
+ label: t`client`,
+ title: t`Handled by ObsidianIRC before being sent`,
+ className:
+ "bg-discord-dark-200 text-discord-text-muted border border-discord-dark-500",
+ };
+ case "server":
+ return {
+ label: t`server`,
+ title: t`Command provided by the IRC server`,
+ className:
+ "bg-emerald-700/40 text-emerald-300 border border-emerald-600/60",
+ };
+ case "bot":
+ return source.scope === "server"
+ ? {
+ // Sky instead of brand purple: the selected-row highlight
+ // is bg-discord-primary, and the old purple badge tint
+ // disappeared against it. Sky still reads as "network-
+ // wide service" and stays legible on both backgrounds.
+ label: t`server-bot`,
+ title: t`Server-wide bot — reachable from any channel`,
+ className: "bg-sky-700/40 text-sky-300 border border-sky-600/60",
+ }
+ : {
+ label: t`channel-bot`,
+ title: t`Channel bot — present in this channel`,
+ className:
+ "bg-amber-700/30 text-amber-300 border border-amber-600/50",
+ };
+ }
+}
+
+function sourceBadge(
+ source: SlashSuggestionSource,
+ t: ReturnType["t"],
+): React.ReactNode {
+ const { label, title, className } = badgeStyle(source, t);
+ return (
+
+ {label}
+
+ );
+}
+
export const SlashCommandPopover: React.FC = ({
isVisible,
inputValue,
@@ -48,16 +140,17 @@ export const SlashCommandPopover: React.FC = ({
onSelect,
onClose,
}) => {
+ const { t } = useLingui();
const cursorPosition = inputElement?.selectionStart ?? inputValue.length;
const query = getActiveSlashQuery(inputValue, cursorPosition);
const [selectedIndex, setSelectedIndex] = useState(0);
const ref = useRef(null);
const matches = useMemo(() => {
- if (query === null) return [] as string[];
+ if (query === null) return [] as SlashSuggestion[];
if (commands.length === 0) return [];
return commands
- .filter((c) => c.startsWith(query))
+ .filter((c) => c.name.toLowerCase().startsWith(query))
.slice(0, MAX_SUGGESTIONS);
}, [commands, query]);
@@ -100,38 +193,76 @@ export const SlashCommandPopover: React.FC = ({
if (!isVisible || query === null || matches.length === 0) return null;
- // Position above the input box, left-aligned.
+ // Pin the popover's bottom edge to the input's top edge (small 6px
+ // gap) and its left edge to the input's left edge. Using `bottom`
+ // instead of `top + computed height` means the popover stays
+ // correctly anchored regardless of how many rows it currently has
+ // or whether any rows carry a description -- no row-height estimate
+ // needed.
const inputRect = inputElement?.getBoundingClientRect();
- const top = inputRect
- ? inputRect.top + window.scrollY - matches.length * 32 - 32
- : 100;
- const left = inputRect ? inputRect.left + window.scrollX : 100;
+ // Refuse to render until the input has a real position on screen --
+ // otherwise the first frame shows the popover anchored at viewport
+ // (100, 100) for a flash before the ref resolves and it snaps to
+ // the input.
+ if (!inputRect || inputRect.height === 0) return null;
+ const bottom = window.innerHeight - inputRect.top + 6;
+ const left = inputRect.left;
return (
-
+
- Slash commands
+ Slash commands
- {matches.map((cmd, index) => (
-
onSelect(cmd)}
- onMouseEnter={() => setSelectedIndex(index)}
- >
- /{cmd}
-
- ))}
+ {matches.map((cmd, index) => {
+ const sig = formatOptions(cmd.options);
+ const isSelected = index === selectedIndex;
+ return (
+
onSelect(cmd)}
+ onMouseEnter={() => setSelectedIndex(index)}
+ >
+
+
+ /{cmd.name}
+ {sig && (
+
+ {sig}
+
+ )}
+
+ {sourceBadge(cmd.source, t)}
+ {cmd.source.kind === "bot" && (
+
+ @{cmd.source.botNick}
+
+ )}
+
+ {cmd.description && (
+
+ {cmd.description}
+
+ )}
+
+ );
+ })}
);
diff --git a/src/components/ui/SlashParamHint.tsx b/src/components/ui/SlashParamHint.tsx
new file mode 100644
index 00000000..303be876
--- /dev/null
+++ b/src/components/ui/SlashParamHint.tsx
@@ -0,0 +1,187 @@
+// Inline hint shown above the chat input once the user has typed the
+// command name and at least one space, e.g.
+//
+// /forecast lon
+// ───────────────────────────────────────────────────
+// /forecast
— Look up the current weather for a city
+// ^^^ city (string, required) via @weather
+//
+// The active parameter (the one the cursor is currently on) is bolded.
+// Only fires for draft/bot-cmds commands -- builtin /op /me etc. don't
+// publish a schema so there's nothing to hint about.
+
+import { Trans } from "@lingui/react/macro";
+import type React from "react";
+import { useEffect, useMemo, useState } from "react";
+import type { BotCommand } from "../../types";
+
+export interface SlashParamSchema {
+ command: BotCommand;
+ source: "client" | "bot";
+ /** Present iff source === "bot" */
+ botNick?: string;
+ scope: "channel" | "server" | "dm";
+}
+
+interface SlashParamHintProps {
+ inputValue: string;
+ cursorPosition: number;
+ /** Map of command name → schema, lowercased keys. */
+ schemas: Record;
+ inputElement?: HTMLInputElement | HTMLTextAreaElement | null;
+}
+
+/** Returns { cmdName, argIndex } when the cursor is inside an arg
+ * position of `/ …`, otherwise null.
+ *
+ * cmdName is the raw form (may include `@botnick`); the caller can
+ * look this up directly against a schemas map keyed by the same
+ * composite form, then fall back to the bare cmd if no specific
+ * entry exists. */
+export function getActiveParamContext(
+ input: string,
+ cursor: number,
+): { cmdName: string; argIndex: number } | null {
+ if (!input.startsWith("/") || input.startsWith("//")) return null;
+ // Strip leading slash, find the command name (before first space).
+ const head = input.slice(1);
+ const firstSpace = head.indexOf(" ");
+ if (firstSpace === -1) return null; // still typing the command name
+ const cmdName = head.slice(0, firstSpace).toLowerCase();
+ const bare = cmdName.includes("@") ? cmdName.split("@")[0] : cmdName;
+ if (!bare) return null;
+
+ // Cursor must be at or past the first space.
+ const cursorInHead = cursor - 1;
+ if (cursorInHead <= firstSpace) return null;
+
+ // Count spaces in head[0..cursorInHead] to figure out which arg.
+ let argIndex = -1; // -1 means still in cmd name region
+ for (let i = 0; i <= cursorInHead && i < head.length; i++) {
+ if (head[i] === " ") argIndex++;
+ }
+ if (argIndex < 0) return null;
+ return { cmdName, argIndex };
+}
+
+export const SlashParamHint: React.FC = ({
+ inputValue,
+ cursorPosition,
+ schemas,
+ inputElement,
+}) => {
+ // The parent only re-renders this hint when its own state changes
+ // (and it intentionally avoids re-rendering on every keystroke for
+ // perf reasons -- input value is held in a ref). Subscribe to the
+ // input element directly so the hint re-evaluates context on every
+ // edit; only then will it correctly disappear when the user edits
+ // the command name to something the schemas don't know about.
+ const [liveValue, setLiveValue] = useState(inputValue);
+ const [liveCursor, setLiveCursor] = useState(cursorPosition);
+
+ useEffect(() => {
+ if (!inputElement) return;
+ const refresh = () => {
+ setLiveValue(inputElement.value);
+ setLiveCursor(inputElement.selectionStart ?? inputElement.value.length);
+ };
+ refresh();
+ inputElement.addEventListener("input", refresh);
+ inputElement.addEventListener("keyup", refresh);
+ inputElement.addEventListener("click", refresh);
+ return () => {
+ inputElement.removeEventListener("input", refresh);
+ inputElement.removeEventListener("keyup", refresh);
+ inputElement.removeEventListener("click", refresh);
+ };
+ }, [inputElement]);
+
+ // Keep parent-provided props as a fallback for the first render
+ // before the listener has fired (and so unit tests that don't wire
+ // an element still get the static read).
+ const effValue = inputElement ? liveValue : inputValue;
+ const effCursor = inputElement ? liveCursor : cursorPosition;
+
+ const ctx = useMemo(
+ () => getActiveParamContext(effValue, effCursor),
+ [effValue, effCursor],
+ );
+
+ if (!ctx) return null;
+ // Try `/cmd@bot` first, then fall back to the bare command name.
+ const bare = ctx.cmdName.includes("@")
+ ? ctx.cmdName.split("@")[0]
+ : ctx.cmdName;
+ const entry = schemas[ctx.cmdName] ?? schemas[bare];
+ if (!entry) return null;
+
+ const opts = entry.command.options ?? [];
+ if (opts.length === 0) return null;
+
+ // Position above the input, same anchor as the popover.
+ const inputRect = inputElement?.getBoundingClientRect();
+ const top = inputRect ? inputRect.top + window.scrollY - 70 : 100;
+ const left = inputRect ? inputRect.left + window.scrollX : 100;
+
+ return (
+
+
+ /{entry.command.name}{" "}
+ {opts.map((o, i) => {
+ const active = i === ctx.argIndex;
+ const text = o.required ? `<${o.name}>` : `[${o.name}]`;
+ return (
+
+ {text}{" "}
+
+ );
+ })}
+
+ {entry.source === "bot" ? (
+ via @{entry.botNick}
+ ) : (
+ (handled by ObsidianIRC)
+ )}
+
+
+ {opts[ctx.argIndex] && (
+
+
+ {opts[ctx.argIndex].name}
+
+ {" — "}
+ {opts[ctx.argIndex].type || "string"}
+ {opts[ctx.argIndex].required && (
+
+ required
+
+ )}
+ {opts[ctx.argIndex].description && (
+ — {opts[ctx.argIndex].description}
+ )}
+
+ )}
+ {/* show choices if present */}
+ {(opts[ctx.argIndex]?.choices?.length ?? 0) > 0 && (
+
+ one of:{" "}
+
+ {opts[ctx.argIndex].choices?.join(", ")}
+
+
+ )}
+
+ );
+};
+
+export default SlashParamHint;
diff --git a/src/components/ui/UserContextMenu.tsx b/src/components/ui/UserContextMenu.tsx
index be677816..52093743 100644
--- a/src/components/ui/UserContextMenu.tsx
+++ b/src/components/ui/UserContextMenu.tsx
@@ -19,6 +19,13 @@ interface UserContextMenuProps {
currentUserStatus?: string;
currentUsername?: string;
onOpenModerationModal?: (action: ModerationAction) => void;
+ /** When the user clicks a bot command in the submenu, the parent
+ * should open the slash-command param modal pre-filled for that
+ * bot + command (mirrors BotsModal.onPickCommand). */
+ onPickBotCommand?: (
+ botNick: string,
+ command: import("../../types").BotCommand,
+ ) => void;
}
export const UserContextMenu: React.FC = ({
@@ -34,6 +41,7 @@ export const UserContextMenu: React.FC = ({
currentUserStatus,
currentUsername,
onOpenModerationModal,
+ onPickBotCommand,
}) => {
const { t } = useLingui();
const menuRef = useRef(null);
@@ -54,6 +62,33 @@ export const UserContextMenu: React.FC = ({
const website = user?.metadata?.url?.value || user?.metadata?.website?.value;
const status = user?.metadata?.status?.value;
+ const isBot = !!user?.isBot || user?.metadata?.bot?.value === "true";
+ const botCommands = useStore(
+ (state) =>
+ state.servers.find((s) => s.id === serverId)?.botCommands?.[
+ username.toLowerCase()
+ ] ?? null,
+ );
+ const requestBotsModalOpen = useStore((state) => state.requestBotsModalOpen);
+ const [botCommandsOpen, setBotCommandsOpen] = useState(false);
+ const botRowRef = useRef(null);
+ const botCloseTimer = useRef | null>(null);
+ const [botSubmenuPos, setBotSubmenuPos] = useState<{
+ left: number;
+ top: number;
+ } | null>(null);
+ const cancelBotClose = () => {
+ if (botCloseTimer.current) {
+ clearTimeout(botCloseTimer.current);
+ botCloseTimer.current = null;
+ }
+ };
+ const scheduleBotClose = () => {
+ cancelBotClose();
+ botCloseTimer.current = setTimeout(() => {
+ setBotCommandsOpen(false);
+ }, 150);
+ };
useEffect(() => {
if (!isOpen) return;
@@ -253,6 +288,58 @@ export const UserContextMenu: React.FC = ({
View Profile
+ {isBot && botCommands && botCommands.length > 0 && (
+ {
+ cancelBotClose();
+ const r = botRowRef.current?.getBoundingClientRect();
+ if (r) {
+ setBotSubmenuPos({ left: r.right + 4, top: r.top });
+ }
+ setBotCommandsOpen(true);
+ }}
+ onMouseLeave={scheduleBotClose}
+ onClick={() => {
+ cancelBotClose();
+ const r = botRowRef.current?.getBoundingClientRect();
+ if (r) {
+ setBotSubmenuPos({ left: r.right + 4, top: r.top });
+ }
+ setBotCommandsOpen((v) => !v);
+ }}
+ className="w-full px-3 py-2 text-left text-discord-text-normal hover:bg-discord-dark-200 hover:text-white transition-colors duration-150 flex items-center gap-2"
+ aria-haspopup="menu"
+ aria-expanded={botCommandsOpen}
+ >
+
+
+ Bot Commands
+
+
+ {botCommands.length}
+
+
+ ▸
+
+
+ )}
{!isOwnUser && (
= ({
)}
+ {/* Bot Commands flyout — portal-sibling of the main menu so it
+ isn't clipped by the menu's overflow-y-auto. Positioned in
+ viewport coords from the row's bounding rect. */}
+ {botCommandsOpen &&
+ botCommands &&
+ botCommands.length > 0 &&
+ botSubmenuPos && (
+
+
+ {
+ requestBotsModalOpen(serverId, username);
+ onClose();
+ }}
+ className="w-full px-3 py-2 text-left text-xs font-semibold text-discord-text-normal hover:bg-discord-dark-200 hover:text-white transition-colors"
+ >
+ Show in Bots Menu →
+
+
+ {botCommands.map((c) => (
+
{
+ if (onPickBotCommand) onPickBotCommand(username, c);
+ onClose();
+ }}
+ className="w-full px-3 py-1.5 text-left hover:bg-discord-dark-200 transition-colors"
+ >
+
+ /{c.name}
+
+ {c.description && (
+
+ {c.description}
+
+ )}
+
+ ))}
+
+ )}
>
);
diff --git a/src/components/ui/UserProfileModal.tsx b/src/components/ui/UserProfileModal.tsx
index 8b9515af..a35b6f73 100644
--- a/src/components/ui/UserProfileModal.tsx
+++ b/src/components/ui/UserProfileModal.tsx
@@ -11,6 +11,7 @@ import {
FaRobot,
FaServer,
FaShieldAlt,
+ FaTerminal,
FaTimes,
FaUser,
FaUserCheck,
@@ -28,6 +29,10 @@ interface UserProfileModalProps {
onBack?: () => void;
serverId: string;
username: string;
+ /** Called when the user clicks "Show in Bots Menu" inside the
+ * Bot Commands section. Implementer should open the BotsModal
+ * pre-selected on this user's nick. */
+ onShowInBotsMenu?: (botNick: string) => void;
}
// Parse channel string into individual channels with status
@@ -81,7 +86,14 @@ const UserProfileModal: React.FC = ({
onBack,
serverId,
username,
+ onShowInBotsMenu,
}) => {
+ const botCommands = useStore(
+ (state) =>
+ state.servers.find((s) => s.id === serverId)?.botCommands?.[
+ username.toLowerCase()
+ ] ?? null,
+ );
const [isLoadingWhois, setIsLoadingWhois] = useState(false);
const [isLoadingMetadata, setIsLoadingMetadata] = useState(false);
const [pendingUrl, setPendingUrl] = useState(null);
@@ -180,10 +192,17 @@ const UserProfileModal: React.FC = ({
// Format idle time
const formatIdleTime = (seconds: number): string => {
- if (seconds < 60) return `${seconds} seconds`;
- if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes`;
- if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`;
- return `${Math.floor(seconds / 86400)} days`;
+ if (seconds < 60) return t`${seconds} seconds`;
+ if (seconds < 3600) {
+ const minutes = Math.floor(seconds / 60);
+ return t`${minutes} minutes`;
+ }
+ if (seconds < 86400) {
+ const hours = Math.floor(seconds / 3600);
+ return t`${hours} hours`;
+ }
+ const days = Math.floor(seconds / 86400);
+ return t`${days} days`;
};
// Format signon time
@@ -392,6 +411,40 @@ const UserProfileModal: React.FC = ({