Skip to content

Bot Tools: draft/bot-tools + draft/bot-cmds (IRCv3 spec)#238

Open
ValwareIRC wants to merge 49 commits into
mainfrom
feat/bot-tools-spec
Open

Bot Tools: draft/bot-tools + draft/bot-cmds (IRCv3 spec)#238
ValwareIRC wants to merge 49 commits into
mainfrom
feat/bot-tools-spec

Conversation

@ValwareIRC
Copy link
Copy Markdown
Contributor

@ValwareIRC ValwareIRC commented May 24, 2026

Reworks and combines two previously-separate feature branches — the workflow viewer (draft-ai-tools) and slash commands (feat/pushbot-client) — to the IRCv3 Bot Tools draft spec.

draft/bot-tools (workflow transparency)

  • Capability draft/bot-tools (was draft/ai-tools); client-only tag +draft/bot-tools (was the vendor +obby.world/ai-tools)
  • Tag value is now base64 of compact JSON (shared, UTF-8-safe helper in src/lib/base64.ts) instead of escaped raw JSON
  • thinkingreasoning; the steer action → input; added the workflow features array (interactive/reasoning/approval) and step cancelled-by

draft/bot-cmds (slash commands)

  • Valueless +draft/bot-cmds-query (was =1)
  • Command schema uses contexts (public/private/pm) + requires, dropping the legacy visibility/scopes/version fields
  • The invocation payload carries the target bot (public-channel disambiguation) and the channel (private context) instead of relying on +draft/channel-context, which is not valid on TAGMSG
  • base64 with padding via the shared util

The obby.world bot-directory layer (obby.world/channel-bots, obby.world/bot-info, the Bots management modal) is intentionally kept as a vendor feature — only its command display now reads draft/bot-cmds.

Testing

  • npm run test — 818 passing, 1 skipped
  • npm run build — clean
  • i18n: 46 new strings translated across all 18 non-English locales (0 missing)

Server side

Requires the companion obbyircd changes on unreal60_dev (commit 11d5d6d76): the draft/bot-tools/draft/bot-cmds capabilities, schema normalisation, +draft/bot-cmd-error, and the legacy-compatibility bridge.

Summary by CodeRabbit

  • New Features
    • Added bot command system enabling slash-command execution via bot integration
    • Added bot directory modal for discovering and managing available bots
    • Added parameter input modal for collecting bot command arguments with validation
    • Added in-chat parameter hints to guide command argument entry
    • Added workflow visualization cards displaying bot/AI action progress and results
    • Added bot invocation attribution labels in messages
    • Extended chat header with bot management controls
    • Added comprehensive multi-language support for bot and workflow features across 8 languages

ValwareIRC added 30 commits May 14, 2026 15:38
Implements client-side support for the draft/ai-tools v0.4 spec:
AI bots stream their workflow state (thinking, tool calls, results)
as TAGMSG carrying a +obby.world/ai-tools JSON envelope; we render
those workflows in a floating tray pinned to the top-right of the
chat area, scoped to the current channel.

Each workflow is its own card -- spinner + bot nick + dropdown
chevron when collapsed, expands to show a Claude-Code-style
timeline of steps (colored dot accent per type, content rendered
in a monospace box for both string fragments and tool-call args).
Multiple bots → multiple stacked cards, so workflow telemetry
never eats into chat real-estate.

Control signals (cancel / approve / reject) round-trip via TAGMSG
to the bot's nick. Pending-approval steps surface inline buttons
inside the card; the card header has a Stop button while running
and a dismiss-X after the workflow reaches a terminal state.

Includes:
- aiTools.ts: decoder/encoder + TS types matching the spec
- src/store/handlers/aiTools.ts: TAGMSG/CHANMSG/USERMSG → workflow
  state machine; string-content fragments concatenate so the
  ai-tools/content-stream batch reassembles correctly even when
  processed message-by-message
- AiToolsCard + AiToolsTray: the floating UI
- ChatArea wires the tray in, scoped to the selected target
- draft/ai-tools added to the CAP REQ list
- Tests: 21 new (decoder edge cases + handler state transitions)
- Translations: all 18 supported locales
Replaces the flat JSON.stringify <pre> dump with a key→value chip
tree. Primitives become inline colored chips (green strings, cyan
numbers, purple bools); nested objects/arrays nest under a tinted
left rule with their entries laid out one-per-row. Much easier to
scan tool-call args once they get more than a couple of fields,
which the user noticed when the bot started passing structured
options through.
'Workflow history' (button title) and '{0} step(s)' (list row summary)
across all 18 non-English locales.
CollapsibleMessage renders as a block-level div, so the inline-flex
pill stacked above the message body rather than sitting next to it.
Float-left makes the body wrap around the chip, prepending it to the
first line as intended.
Extends the workflow message schema with an optional `prompt` field
that bots can use to ship a short truncated copy of the trigger
message. The card renders it in italic muted text under the workflow
name so users see what was asked without scrolling back to the
trigger PRIVMSG. Field is optional -- bots that don't include it are
unaffected, the card just omits the line.

Plumbed end-to-end: lib/aiTools.ts decoder, AiWorkflow store type,
applyWorkflowUpdate merge, AiToolsCard render.
Going from 60s to 5s means the previous 'fade only in the last 10s'
logic would start with the card already half-transparent, so switch
the opacity easing to span the full countdown (1.0 -> 0.15) instead.
Live workflows now claim a chat slot the moment the start TAGMSG lands.
The slot shows the workflow name + a live preview of the latest step
with a spinner, then morphs in place into the bot's final PRIVMSG
(carrying the workflow pill) once it arrives -- no row jump.

Historical (chathistory-replayed) workflows no longer pop floating
tray cards on channel join; they remain in the workflow history popover
and the inline pill on the original message.
…holder

Two issues with the in-chat placeholder flow:

1. When the bot's tagged PRIVMSG morphs the placeholder, we were not
   resolving the +reply tag, so the morphed row had no reply quote
   block (whereas an untagged reply landed via the normal path with
   quote intact).  Now resolveReplyMessage + extractMentions run on
   the morph path too.

2. If the bot's final PRIVMSG doesn't carry the workflow tag (only a
   terminal-state TAGMSG is sent), the morph path never fires and the
   placeholder is stuck on a "Thinking…" spinner while the bot's
   actual reply lands as a separate row.  On terminal-state TAGMSG,
   remove the still-pending placeholder so the bot's reply can land
   normally with full reply context.
The tray filters out historical workflows so chathistory replay
doesn't pop a wall of cards on channel join, but the inline pill's
reopen action was leaving `historical` set -- meaning even after
explicit click the card stayed filtered out and nothing happened.
Clear it on reopen so an explicit view request always surfaces the
card.
The pill used to sit in a flex column to the left of the body, which
pushed the entire message content sideways and made the reply look
visually off-axis from neighbouring messages.  Strip it to a bare
clickable icon (step count + name already live on the floating card
and history popover) and absolutely-position it in the avatar gutter
so the body keeps its natural alignment.
…g from count

Two changes to how workflow steps are presented:

1. Display: a tool-call and its matching tool-result now render as a
   single row -- tool name in the header, then IN (call args) and OUT
   (result content) stacked, matching the Claude Code "Bash → Commit
   and push" pattern.  Pairing is FIFO by tool name; an orphan result
   (no preceding call) still renders.  Thinking / text rows are
   unchanged.

2. Counting: countableSteps() now powers the inline pill tooltip and
   the history popover label.  It excludes thinking (model muttering,
   not work) and counts a tool-call/result pair as one step instead
   of two.
The auto-scroll effect only fired when the user was already at the
bottom, which never happens on initial expand (scrollTop is 0).
Track a "has-done-initial-scroll" flag per expansion: force-scroll
to the bottom the first time the body mounts/expands, then fall back
to the at-bottom check for subsequent step updates so a user
scrolled-up to review earlier output isn't yanked away.
…check

The previous approach checked "is the user at the bottom?" inside
the content-update effect, but by then the new content had already
grown scrollHeight -- the old scrollTop no longer qualified as
bottom, so we never auto-scrolled even when the user was parked
there.

Track stickiness via the scroll event instead: whenever the user
(or we) scrolls, recompute whether they're at the bottom and stash
that into a ref.  On content update, honour the flag.  Reset to
sticky on collapse so a fresh expansion always lands on the latest
content.
Adds first-class support for PushBot-style slash commands as defined by
the obbyircd doc/pushbot-spec.md protocol.  The user types /forecast
london in #weather and the client:

  1. Looks up "forecast" in the per-server botCommands cache (keyed
     by lowercased bot nick).
  2. If a bot in the current channel exposes that command, builds a
     base64-encoded JSON payload { name, options } and sends
     @+draft/bot-cmd=<b64> TAGMSG <#channel|botnick>, picking the
     PRIVMSG-style "public" or NOTICE-style "private" wire form based
     on the command's visibility field.
  3. Falls back to the raw IRC command path if no match -- existing
     /op, /me etc. behaviour is preserved.

Discovery is event-driven:

  * draft/bot-cmds added to ourCaps so the server knows we're aware
    of the protocol (informational; the server still does the work).
  * registerPushBotHandlers (new src/store/handlers/pushbot.ts) hooks
    TAGMSG, decodes +draft/bot-cmds responses, and writes to
    server.botCommands.  A +draft/bot-cmds-changed broadcast clears
    the cached entry so the next slash invocation triggers a refetch.
  * The handler is wired into registerAllHandlers in
    src/store/handlers/index.ts.

Types extended:
  * Server.botCommands: Record<botNick, BotCommand[]> on src/types.
  * BotCommand / BotCommandOption mirror the JSON the bot publishes
    (name, description, visibility, scopes, options[]).

Resolution order in tryDispatchBotCommand mirrors §7.5 of the spec:
explicit /cmd@botnick targets first, then channel-bots, then DM
partners, then server-wide bots.  Public invocations go to the
channel (everyone sees the reply); private ones go to the bot with
+draft/channel-context to keep whisper-style replies routed to the
right view.

Tests: tests/store/pushbot.test.ts covers cache population,
+draft/bot-cmds-changed invalidation, and that unrelated TAGMSGs are
ignored.  All 60 test files (789 tests) still pass.
After RPL_ENDOFWHO (315) for a channel, scan the channel's user list
for users marked isBot=true (set by handleWhoxReply when the +B mode
flag is present) and send a +draft/bot-cmds-query TAGMSG to each
that we don't already have cached.  Result: by the time the user
types '/' in a channel with bots in it, the autocomplete list is
already populated.
Two visible-to-the-user gaps now closed:

1. ChatArea rendered the slash popover from `cmdsAvailable` only.
   Merge in command names from `server.botCommands` so /forecast
   shows up in the popover when the user is in a channel with a
   bot that has registered it.

2. Discovery used to fire only on WHO_END.  Add a lazy
   `queryUncachedBotsInChannel` triggered the first time the user
   starts a slash command in a channel that has +B users without
   cached schemas, so the popover catches up if WHO completed
   before the handler attached.
Previously the popover rendered every suggestion as just `/name`; the
caller didn't know whether the source was a server-side built-in or a
PushBot, and there was no signal that /forecast even takes arguments.

Now:

* `SlashSuggestion` carries name + description + options[] + source
  ({kind:"builtin"} or {kind:"bot", botNick, scope:"channel"|"server"}).
  ChatArea builds these from cmdsAvailable (builtins) and from
  server.botCommands (PushBot schemas).
* The popover renders the bare name plus a "channel-bot" / "server-bot"
  badge with the bot nick, the description below, and `<required>` /
  `[optional]` placeholders inline next to the name.  Channel-bots
  only appear when their bot is a member of the active channel;
  server-wide bots show up everywhere we have a cached schema.
* A new SlashParamHint floats above the input once the user has typed
  past the command name -- it highlights the active argument (the one
  the cursor is currently in), shows the param's type, "required"
  tag, description, and any `choices` list.  Disappears for builtins
  (no schema) and once the user has scrolled past all declared opts.

Tests: 6 new cases for getActiveParamContext covering cmd-name
typing, `//foo` escape, position 0..N argument tracking, `/cmd@bot`
targeting suffix, and case-insensitive cmd matching.  All 61 test
files (795 tests) green.
The popover was missing the React-side commands (/me, /msg, /nick,
/whisper, /join, /part, /away, /back) because they're handled
locally before they touch the wire and never appear in the
obsidianirc/cmdslist set.  Centralized them in
src/lib/clientCommands.ts with full schemas (description + options)
so the popover and the param-hint render them identically to
PushBot commands.

Source kinds now distinguish:
  * client → handled locally; slate badge, "(handled by ObsidianIRC)"
  * server → from obsidianirc/cmdslist; emerald badge
  * bot → draft/bot-cmds; amber "channel-bot" or purple "server-bot"

Dedup is client > server > bot, so /me always renders as client
even if the server's cmdslist also advertises it.  Badges have
hover-tooltips explaining the source.
Two passes squashed:

(1) Negotiate the obby.world/channel-bots capability, receive the
    server's bot directory burst at welcome time (BATCH wrapper, one
    TAGMSG per bot carrying obby.world/bot-info=<base64-json>) plus
    incremental add/update/remove pushes.  State lands in server.bots
    (Record<lowerNick, PushBotInfo>) and mirrors into botCommands so
    the slash popover stays warm without a separate
    +draft/bot-cmds-query.

(2) New BotsModal — left pane: filter (All / Server-wide / Channel) +
    search + scope/status badges and online dot; right pane: realname,
    transport, joined channels, command schemas, IRCop action buttons
    (Approve / Suspend / Unsuspend / Delete) for non-config-defined
    bots.  Wired into ChatHeader via onOpenBots, both as a desktop
    icon button (🤖, hidden md:block) and as an overflow-menu entry
    for narrow/mobile views -- the first cut only added the overflow
    entry which is invisible on desktop widths.

Tests: 2 new vitest cases for the bot-info pipeline (add populates
server.bots + botCommands; remove clears both).  61 test files,
797 tests green.
…cons

The first cut of BotsModal was a one-off custom layout (flex split,
non-portal, ad-hoc styling) using a 🤖 emoji as a header decoration.
That worked but it didn't match the rest of the app and looked off
on mobile.

Rework it to mirror UserSettings:
* useMediaQuery to branch desktop vs mobile
* useModalBehavior for escape / click-outside
* desktop: backdrop + centered card (max-w-4xl, h-80vh) with a fixed-
  width sidebar (filterable bot list) and a content pane (selected
  bot detail); Discord-dark palette and discord-primary accents.
* mobile: full-screen createPortal with two views (list → detail
  drill-in, back button to return), safe-area padding matching
  UserSettings.

Icons: every emoji used as UI chrome now uses react-icons/fa.
  * 🤖 channel-header button → <FaRobot />
  * 🤖 overflow-menu entry → <FaRobot />
  * online indicator dot in the bot list → <FaCircle />
  * empty-state placeholder → <FaRobot className="text-4xl" />

Same surface area: filter chips, search input, status/scope badges,
IRCop action buttons (Approve / Suspend / Unsuspend / Delete) for
non-config-defined bots.  The 🤖 next to bot nicknames in chat is
unchanged -- that's a pre-existing bot identity marker, not UI chrome.
…dslist

Two bugs:

1) Server-scope bots (helpbot, dicebot) never showed up in the picker.
   The picker skipped any bot that wasn't a channel member, which was
   right for channel-scope bots but wrong for server-scope bots that
   never auto-join.  Now: gate the membership check on the bot's
   scope; server-scope bots are always offered.

2) When a server's cmdsAvailable advertised a name (e.g. HELP) that
   a bot also defined (helpbot's /help), the server entry won the
   dedup set and the bot was shadowed; the hint code meanwhile pulled
   the bot's schema for that name, producing the "picker says it's
   the built-in but the hint reads like the bot" mismatch.  Process
   bot commands before cmdsAvailable so the canonical bot owner wins
   the seen-set, and apply the same scope filter to the hint schemas.

Drive-by: replace `choices!.length` with `(choices?.length ?? 0)` in
SlashParamHint that fix:unsafe had downgraded to an unsafe optional
chain on the comparator.
The selected-row highlight is bg-discord-primary (blurple #5865F2),
and the server-bot badge was bg-discord-primary/30 + text-discord-
primary -- meaning the badge disappeared against its own selected
row.  Sky-700/40 + sky-300 reads as "network-wide service",
contrasts every other badge state, and stays legible on both the
default dark row and the selected blurple row.
When a bot publishes a command with declared options, picking it
from the slash popover now opens a one-per-option form modal
instead of putting the user on the hook to remember positional
arg ordering and free-form-type each value.

Renderer is type-driven from BotCommandOption.type:

  string             text input
  int / number       numeric input (step=1 / step=any)
  bool               checkbox
  user               text + datalist of channel members + DM partner
  channel            text + datalist of joined channels
  date / time /
  datetime           native HTML picker
  country            select w/ ISO-3166-1 flag + name; wire value is
                     the alpha-2 code (lib/countries.ts)
  password           masked text input
  any with choices[] select of the bot-declared choices (overrides
                     the type renderer)

useMessageSending grew an exported sendBotCommand() that takes a
pre-resolved bot + structured options map -- bypassing the
positional-arg parser used by the inline-typing path.

Translations for the 5 new strings added across all 18 non-English
locales.
Two related fixes around the slash-command UX:

1) The SlashParamHint stuck around after editing the command name to
   something the schemas didn't recognise, because the hint was
   rendered from messageTextRef.current (a ref, no re-render trigger)
   while the parent intentionally skips re-renders on every keystroke
   for perf.  Subscribe to the input element's events from inside the
   hint so it re-evaluates context independently and disappears as
   soon as the cmd-name no longer matches.

2) Public slash commands are sent as a TAGMSG, so other channel
   members only see the bot's reply with no context for what
   triggered it.  Render an attribution chip above the bot's reply
   when it carries the new +obby.world/invoked-by tag -- a base64
   JSON snapshot of {nick, name, options} produced server-side at
   dispatch time.

The server-side half of (2) lands in pushbot.c: PbInteraction gains
an invoker_cmd_b64 field built during pb_dispatch_command, and
pb_make_reply_tags appends +obby.world/invoked-by alongside the
existing +reply / +draft/channel-context tags.
Two related changes pair with the server-side discovery split:

1) Each slash command in BotDetail is now a button; clicking it
   opens SlashCommandParamModal with the bot + command pre-set.
   Submit dispatches via the existing sendBotCommand path, which
   routes by command.visibility (public -> current channel,
   private -> DM to the bot with channel-context).  BotsModal
   closes when a command is picked so the param modal isn't
   stacked underneath.

2) The bot list filters by reachability so the user only sees
   bots they can actually invoke right now: server-scope always,
   channel-scope only if any of bot.channels overlaps a channel
   they're a member of.  Opers bypass the filter for management.

Companion server-side change (pushbot.c) restricts the connect
burst to server-scope bots only; channel-scope arrives on the
relevant LOCAL_JOIN.  Opers still get every bot in the burst.
# Conflicts:
#	src/components/message/MessageItem.tsx
#	src/locales/cs/messages.mjs
#	src/locales/cs/messages.po
#	src/locales/de/messages.mjs
#	src/locales/de/messages.po
#	src/locales/en/messages.mjs
#	src/locales/en/messages.po
#	src/locales/es/messages.mjs
#	src/locales/es/messages.po
#	src/locales/fi/messages.mjs
#	src/locales/fi/messages.po
#	src/locales/fr/messages.mjs
#	src/locales/fr/messages.po
#	src/locales/it/messages.mjs
#	src/locales/it/messages.po
#	src/locales/ja/messages.mjs
#	src/locales/ja/messages.po
#	src/locales/ko/messages.mjs
#	src/locales/ko/messages.po
#	src/locales/nl/messages.mjs
#	src/locales/nl/messages.po
#	src/locales/pl/messages.mjs
#	src/locales/pl/messages.po
#	src/locales/pt/messages.mjs
#	src/locales/pt/messages.po
#	src/locales/ro/messages.mjs
#	src/locales/ro/messages.po
#	src/locales/ru/messages.mjs
#	src/locales/ru/messages.po
#	src/locales/sv/messages.mjs
#	src/locales/sv/messages.po
#	src/locales/tr/messages.mjs
#	src/locales/tr/messages.po
#	src/locales/uk/messages.mjs
#	src/locales/uk/messages.po
#	src/locales/zh-TW/messages.mjs
#	src/locales/zh-TW/messages.po
#	src/locales/zh/messages.mjs
#	src/locales/zh/messages.po
Combines the workflow-viewer (draft-ai-tools) and slash-command (pushbot-client)
features and aligns both with the IRCv3 "Bot Tools" draft spec.

bot-tools (workflow transparency):
- cap draft/bot-tools (was draft/ai-tools); tag +draft/bot-tools (was the vendor
  +obby.world/ai-tools)
- tag value is base64 of compact JSON (shared, UTF-8-safe src/lib/base64.ts)
- rename thinking -> reasoning and the steer action -> input; add the workflow
  features array and step cancelled-by

bot-cmds (slash commands):
- valueless +draft/bot-cmds-query (was =1)
- command schema uses contexts (public/private/pm) and requires, dropping the
  legacy visibility/scopes/version fields
- the invocation payload carries the target bot (public disambiguation) and the
  channel (private context) rather than relying on +draft/channel-context, which
  is not valid on TAGMSG
- base64 with padding via the shared util

i18n: the 46 new strings translated across all 18 non-English locales.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 24, 2026

PR changed again? Review this PR in Change Stack to compare snapshots and stay oriented.

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99b137ca-58ff-4e85-bbfe-8f61545765ac

📥 Commits

Reviewing files that changed from the base of the PR and between 0f162e9 and 4b23819.

📒 Files selected for processing (61)
  • src/components/layout/ChatArea.tsx
  • src/components/layout/ChatHeader.tsx
  • src/components/message/BotInvocationChip.tsx
  • src/components/message/BotToolsMessagePill.tsx
  • src/components/message/BotToolsPlaceholderBody.tsx
  • src/components/message/MessageItem.tsx
  • src/components/ui/AddServerModal.tsx
  • src/components/ui/BotToolsCard.tsx
  • src/components/ui/BotToolsHistoryButton.tsx
  • src/components/ui/BotToolsTray.tsx
  • src/components/ui/BotsModal.tsx
  • src/components/ui/SlashCommandPopover.tsx
  • src/components/ui/SlashParamHint.tsx
  • src/hooks/useMessageSending.ts
  • src/lib/botTools.ts
  • src/lib/clientCommands.ts
  • src/lib/irc/IRCClient.ts
  • src/locales/cs/messages.mjs
  • src/locales/cs/messages.po
  • src/locales/de/messages.mjs
  • src/locales/de/messages.po
  • src/locales/en/messages.mjs
  • src/locales/en/messages.po
  • src/locales/es/messages.mjs
  • src/locales/es/messages.po
  • src/locales/fi/messages.mjs
  • src/locales/fi/messages.po
  • src/locales/fr/messages.mjs
  • src/locales/fr/messages.po
  • src/locales/it/messages.mjs
  • src/locales/it/messages.po
  • src/locales/ja/messages.mjs
  • src/locales/ja/messages.po
  • src/locales/ko/messages.mjs
  • src/locales/ko/messages.po
  • src/locales/nl/messages.mjs
  • src/locales/nl/messages.po
  • src/locales/pl/messages.mjs
  • src/locales/pl/messages.po
  • src/locales/pt/messages.mjs
  • src/locales/pt/messages.po
  • src/locales/ro/messages.mjs
  • src/locales/ro/messages.po
  • src/locales/ru/messages.mjs
  • src/locales/ru/messages.po
  • src/locales/sv/messages.mjs
  • src/locales/sv/messages.po
  • src/locales/tr/messages.mjs
  • src/locales/tr/messages.po
  • src/locales/uk/messages.mjs
  • src/locales/uk/messages.po
  • src/locales/zh-TW/messages.mjs
  • src/locales/zh-TW/messages.po
  • src/locales/zh/messages.mjs
  • src/locales/zh/messages.po
  • src/store/handlers/botTools.ts
  • src/store/handlers/index.ts
  • src/store/index.ts
  • src/types/index.ts
  • tests/lib/botTools.test.ts
  • tests/store/botTools.test.ts
✅ Files skipped from review due to trivial changes (8)
  • src/locales/cs/messages.mjs
  • src/locales/es/messages.mjs
  • src/locales/es/messages.po
  • src/locales/it/messages.po
  • src/locales/fi/messages.po
  • src/locales/en/messages.po
  • src/locales/cs/messages.po
  • src/locales/de/messages.po
🚧 Files skipped from review as they are similar to previous changes (11)
  • src/lib/irc/IRCClient.ts
  • src/locales/en/messages.mjs
  • src/locales/de/messages.mjs
  • src/locales/it/messages.mjs
  • src/components/message/BotInvocationChip.tsx
  • src/locales/fr/messages.mjs
  • src/components/ui/SlashCommandPopover.tsx
  • src/locales/fi/messages.mjs
  • src/components/ui/BotsModal.tsx
  • src/components/ui/SlashParamHint.tsx
  • src/hooks/useMessageSending.ts

📝 Walkthrough

Walkthrough

This PR implements a comprehensive bot command and workflow system for ObsidianIRC. It adds bot command dispatch via IRCv3 tags, workflow state visualization in the UI, slash command parameter collection, a bot directory modal, and full multilingual support across eight locales.

Changes

Bot tools, slash commands, and bots directory

Layer / File(s) Summary
Utility libraries: base64, countries, client commands
src/lib/base64.ts, src/lib/countries.ts, src/lib/clientCommands.ts
Adds UTF-8–safe base64 encoding/decoding, ISO-2 country codes with flag emoji support, and refactors client command registry to expose getClientCommands() function with per-command option metadata.
Bot tools protocol: encoding, types, and helpers
src/lib/botTools.ts
Defines IRCv3 bot-tools workflow message schema with step/action discriminators, base64-compact JSON codec, step-counting logic (collapsing tool-call/tool-result pairs), and IRC tag value escaping.
Slash command parameter collection and hints
src/components/ui/SlashCommandParamModal.tsx, src/components/ui/SlashParamHint.tsx
SlashCommandParamModal renders typed inputs per bot command option with validation and submission; SlashParamHint displays live argument hints with required/optional/choice/type hints based on cursor position.
Slash command popover: suggestion types and rendering
src/components/ui/SlashCommandPopover.tsx
Refactors popover to work with structured SlashSuggestion objects (client/server/bot sources) instead of plain strings, with option signature formatting and source badging.
Bot command dispatch and sending
src/hooks/useMessageSending.ts
tryDispatchBotCommand resolves slash commands via /cmd@botnick or channel/server-wide bot lookup with argument type coercion; sendBotCommand encodes and sends via TAGMSG with context-aware payload shaping (public/private/pm).
Bots modal directory and management
src/components/ui/BotsModal.tsx
Desktop/mobile-responsive bot directory with search, scope filtering, bot detail view, slash commands display, and operator-only IRC /PUSHBOT actions (approve/suspend/unsuspend/delete).
Bot tools workflow and message UI components
src/components/ui/BotToolsCard.tsx, src/components/ui/BotToolsTray.tsx, src/components/ui/BotToolsHistoryButton.tsx, src/components/message/BotToolsMessagePill.tsx, src/components/message/BotToolsPlaceholderBody.tsx
Renders step-by-step workflow execution, in-progress placeholders, approval prompts, auto-dismiss countdown, workflow history popover, and message-level reopen buttons.
Bot command invocation chip
src/components/message/BotInvocationChip.tsx
Decodes and renders bot command attribution on messages showing invoker nick, "ran" label, command name, and formatted options.
Chat header: bot buttons and history
src/components/layout/ChatHeader.tsx
Adds "Bots" menu item and desktop action button; conditionally renders BotToolsHistoryButton for channels and private chats.
ChatArea: slash commands, bots modal, and bot tools tray
src/components/layout/ChatArea.tsx
Introduces paramModal/botsModalOpen state, lazy bot schema queries, merged suggestion list (client + bot + server commands), updated selection behavior for bot params, SlashParamHint rendering, BotsModal wiring, and BotToolsTray injection.
Message item: bot invocation and workflow display
src/components/message/MessageItem.tsx
Renders BotInvocationChip from message tag; conditionally shows BotToolsPlaceholderBody for pending workflows; positions BotToolsMessagePill in avatar gutter.
AddServerModal host URL normalization
src/components/ui/AddServerModal.tsx
Updates Tauri host normalization to strip scheme and port/path via regex.
IRC capabilities negotiation
src/lib/irc/IRCClient.ts
Adds draft/bot-tools, draft/bot-cmds, and obby.world/channel-bots to CAP negotiation.
Localization: bot tools and bot commands UI
src/locales/*/messages.mjs, src/locales/*/messages.po
Adds Czech, German, English, Spanish, Finnish, French, and Italian translations for bot management, parameter validation, workflow states, and client command help text.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • matheusfillipe

Poem

🤖 A bot command flows through the wire,
Parameters typed and sent up higher,
Workflows dance through the UI view,
Hint by hint, step by step true—
Bots unite in a modal so bright! ✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bot-tools-spec

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (3)
src/lib/base64.ts (1)

17-18: 💤 Low value

Consider using Uint8Array.from() for cleaner byte array construction.

The manual loop works correctly, but Uint8Array.from() offers a more idiomatic alternative.

♻️ Optional refactor
-  const bytes = new Uint8Array(bin.length);
-  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+  const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/base64.ts` around lines 17 - 18, Replace the manual loop that builds
the byte array from the string by using Uint8Array.from for a cleaner idiomatic
conversion: instead of creating bytes = new Uint8Array(bin.length) and iterating
with charCodeAt, call Uint8Array.from(bin, ch => ch.charCodeAt(0)) (using the
existing variables `bin` and `bytes`) so the byte array is constructed in one
expression and the explicit for-loop can be removed.
src/components/layout/ChatArea.tsx (1)

2382-2391: ⚡ Quick win

Trim newly added comments to one-line “why” notes (or remove).

The new blocks are multi-line and mostly narrate what the code is doing; these should be reduced to concise project-context “why” comments.

As per coding guidelines src/**/*.{ts,tsx}: Comments must explain why, never what — if code is readable, omit the comment entirely. Keep comments to one line and write in project context, not change context.

Also applies to: 2403-2408, 2512-2514

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/ChatArea.tsx` around lines 2382 - 2391, The multi-line,
descriptive comments around the slash-command suggestion logic (near symbols
servers, selectedServerId, suggestions, SlashSuggestion, seen, and
inChannelView/selectedChannel) should be reduced to single-line "why" notes or
removed; replace each multi-line narration (including the blocks around lines
~2403-2408 and ~2512-2514) with a concise one-line comment explaining the
intent/context (e.g., why client-side handlers are prioritized) or delete if the
code is self-explanatory, ensuring comments follow the rule “explain why, not
what.”
src/components/ui/SlashParamHint.tsx (1)

1-12: ⚡ Quick win

Reduce comment blocks to one-line “why” statements.

The added comments are mostly multi-line implementation narration; they should be shortened or removed where the code is already clear.

As per coding guidelines src/**/*.{ts,tsx}: Comments must explain why, never what — if code is readable, omit the comment entirely. Keep comments to one line and write in project context, not change context.

Also applies to: 72-77, 98-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/SlashParamHint.tsx` around lines 1 - 12, Reduce the verbose
multi-line implementation comments in the SlashParamHint component to
single-line "why" statements (or remove them) — specifically replace the block
at the top and similar blocks around the areas noted (lines referenced in the
review: 72–77 and 98–101) with concise one-line comments that explain
intent/context only; locate the comments inside the
src/components/ui/SlashParamHint.tsx file near the SlashParamHint component and
its helper functions and trim them to project-style "why" comments or delete if
the code is self-explanatory.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/layout/ChatArea.tsx`:
- Around line 2410-2423: The channel-scoped bot filtering lets channel-only bots
leak into PMs and fails on mixed-case bot nicks; fix by comparing botNick in
lowercase against chanUsers and by treating absence of selectedChannel as
outside the channel. Specifically, compute inChannel using
chanUsers.has(botNick.toLowerCase()) (keep using the original botNick when
indexing srv.bots) and change the guard from `if (botScope === "channel" &&
selectedChannel && !inChannel) continue;` to `if (botScope === "channel" &&
(!selectedChannel || !inChannel)) continue;` and apply the same two changes in
the duplicate block around lines 2543-2555.

In `@src/components/message/AiToolsPlaceholderBody.tsx`:
- Around line 35-41: The helper function stepLabel(AiStep) returns hardcoded
English labels ("Reasoning" and "Text"); to localize, move translation into a
context where useLingui() is available (e.g., inside the component that calls
stepLabel) or change stepLabel to accept a translator function (e.g., pass in
i18n._ or t) and replace the hardcoded returns with translated strings; ensure
you do not call the t macro at module scope—invoke the translator inside a
function body (e.g., component render or stepLabel when given the translator)
and update all call sites accordingly.

In `@src/components/message/BotInvocationChip.tsx`:
- Around line 18-31: The inline decode function duplicates padding logic but
uses atob which mishandles UTF-8; replace its use of atob(...) with the shared
utility base64DecodeUtf8 to ensure correct UTF-8 decoding (keep the existing
padding normalization logic: compute pad, build padded string, then call
base64DecodeUtf8(padded) and JSON.parse the returned string), and still preserve
the try/catch behavior and the return type (Decoded | null) in the decode
function.

In `@src/components/ui/AiToolsCard.tsx`:
- Around line 418-427: In AiToolsCard's useEffect that manages the countdown,
remove the Zustand action reference dismiss from the dependency array (keep
secondsLeft, paused, workflow.serverId, workflow.id) and suppress the
Biome/react-hooks rule for that hook line by adding the appropriate eslint/biome
disable comment above the useEffect; keep the call to dismiss(workflow.serverId,
workflow.id) inside the effect but do not include dismiss as a dependency since
it's a stable store action.
- Around line 473-520: The header currently uses a <button> wrapper in
AiToolsCard (onToggle / aria-expanded tied to workflow.collapsed) but also
renders nested interactive <button> children for dismiss and cancel; change the
outer wrapper to a non-nested interactive pattern (e.g., replace the outer
<button> with a <div role="button" tabIndex={0} onClick={onToggle} onKeyDown
handler that triggers onToggle when Enter/Space are pressed, keep aria-expanded
bound to !workflow.collapsed) and convert the dismiss and cancel controls back
into native <button>s (or ensure the inner controls remain buttons while the
outer becomes a non-button container) so no buttons are nested; additionally, in
the auto-dismiss useEffect that references the Zustand action dismiss, remove
dismiss from the dependency array and add the project’s Biome suppression
comment (// biome-ignore lint/correctness/useExhaustiveDependencies: <reason>)
to satisfy the repo pattern while leaving other deps intact.

In `@src/components/ui/BotsModal.tsx`:
- Around line 373-377: The search filter in BotsModal (the .filter callback
using variable query and properties b.nick and b.realname) can throw when
b.realname is undefined; update the predicate to guard realname by using a safe
fallback or optional chaining before calling toLowerCase (e.g., use (b.realname
?? '').toLowerCase() or b.realname?.toLowerCase() ?? ''), so both nick and
realname comparisons are always called on strings when query is present.
- Around line 34-76: STATUS_BADGE, SCOPE_BADGE and FILTER_LABELS currently
contain hardcoded English labels/titles at module scope which prevents Lingui
extraction; keep the style/class entries in those records but remove/ignore the
label/title strings, then use Lingui translations (t(...) or <Trans>) at render
time when creating the badge text and tooltip (replace usages of
STATUS_BADGE[status].label / .cls and SCOPE_BADGE[scope].label / .title / .cls
by pulling .cls from the mapping and generating the label/title via t or <Trans>
inside the component render where badges are built), and for FILTER_LABELS keep
only keys needed for branching (no UI text) and render the filter button labels
with existing t/<Trans> calls instead of reading them from FILTER_LABELS.

In `@src/components/ui/SlashCommandParamModal.tsx`:
- Around line 368-392: In SlashCommandParamModal.tsx update the hardcoded
user-facing strings (e.g. the input placeholders "nick" and "`#channel`" and any
hardcoded required label on the param field around lines where the "channel" and
other cases render) to use the i18n helpers: call useLingui() to get const { t }
= useLingui() and replace placeholder="nick" and placeholder="`#channel`" with
placeholder={t`...`} (or t('...') per project pattern), and wrap any JSX
children strings such as the required marker with <Trans>…</Trans> from
`@lingui/macro`; ensure you update the relevant components/props in the case
branches (the input in the "channel" case and the corresponding required text
referenced near lines 421-423) so all user-visible text is localized.

In `@src/components/ui/SlashCommandPopover.tsx`:
- Around line 195-197: The popover horizontal position is incorrectly offset by
window.scrollX because inputRect comes from getBoundingClientRect()
(viewport-relative); update the left calculation in SlashCommandPopover so it
uses inputRect.left directly (i.e., const left = inputRect ? inputRect.left :
100) instead of adding window.scrollX, keeping the same fallback when inputRect
is null; leave the bottom computation (const bottom = inputRect ?
window.innerHeight - inputRect.top + 6 : 100) unchanged.
- Around line 80-126: badgeStyle currently returns raw label and title strings
(client/server/server-bot/channel-bot) which bypass Lingui; change badgeStyle to
only return a stable key or enum (e.g., kind and scope) and className, then move
creation of user-facing strings into sourceBadge: import useLingui()/t and wrap
the visible label with <Trans> in the JSX and derive the title via t(...) inside
sourceBadge (or the component render path) so titles are translated; update
sourceBadge to call badgeStyle for className and use the kind/scope key to
select the appropriate <Trans> label and t(...) title before rendering.

In `@src/components/ui/SlashParamHint.tsx`:
- Around line 148-152: The JSX renders user-visible literals in the
SlashParamHint component (e.g., the span that shows `via @${entry.botNick}` and
other text at the later occurrences) which must be localized; import Trans from
'`@lingui/macro`' at the top of the file and wrap each literal JSX child with a
<Trans>…</Trans> macro (for example replace the ternary string branches and the
plain "(handled by ObsidianIRC)" text with Trans-wrapped content, and apply the
same change to the other occurrences around the entry usage at the other two
locations referenced in the review). Ensure interpolation for entry.botNick uses
Trans's placeholder/interpolation syntax rather than raw template strings so
translations receive the variable.

In `@src/hooks/useMessageSending.ts`:
- Around line 120-123: The code computes contexts, canPublic and canPrivate
(contexts = cmd.contexts ?? ["public"]; canPublic = contexts.includes("public");
canPrivate = contexts.includes("private")), but the fallback path currently
dispatches a PM route even when the command disallows private usage; update the
logic in useMessageSending.ts to only send the fallback PM/DM route when
canPrivate is true (i.e., check canPrivate before invoking the PM dispatch), and
apply the same guard to the analogous block around the later fallback (the block
referenced at lines 141-146) so no private dispatch happens for
context-restricted commands.

In `@src/lib/aiTools.ts`:
- Around line 96-149: The decodeAiToolsValue branch handlers (in the function
decodeAiToolsValue) currently cast any string to enum types (e.g.,
AiWorkflowState, AiStepType, AiStepState, AiActionType) which lets unknown
values through; update each case ("workflow", "step", "action") to explicitly
validate incoming strings against the allowed enum/value sets before casting and
return null on mismatch. Implement small runtime checks (e.g., helper
isValidAiWorkflowState, isValidAiStepType, isValidAiStepState,
isValidAiActionType or lookup sets) and use those to gate assigning
state/type/action on the AiWorkflowMessage/AiStepMessage/AiActionMessage objects
instead of direct as-casts; keep existing null-return behavior for invalid
values.

In `@src/locales/fi/messages.po`:
- Around line 1764-1766: Update the Finnish translation for the status label
with msgid "offline" so the UI shows a proper Finnish string instead of the
English fallback; locate the msgid "offline" entry (referenced by the BotsModal
component) and change msgstr "offline" to the Finnish translation (e.g., "ei
verkossa") preserving the same quoting and formatting in the .po file.

---

Nitpick comments:
In `@src/components/layout/ChatArea.tsx`:
- Around line 2382-2391: The multi-line, descriptive comments around the
slash-command suggestion logic (near symbols servers, selectedServerId,
suggestions, SlashSuggestion, seen, and inChannelView/selectedChannel) should be
reduced to single-line "why" notes or removed; replace each multi-line narration
(including the blocks around lines ~2403-2408 and ~2512-2514) with a concise
one-line comment explaining the intent/context (e.g., why client-side handlers
are prioritized) or delete if the code is self-explanatory, ensuring comments
follow the rule “explain why, not what.”

In `@src/components/ui/SlashParamHint.tsx`:
- Around line 1-12: Reduce the verbose multi-line implementation comments in the
SlashParamHint component to single-line "why" statements (or remove them) —
specifically replace the block at the top and similar blocks around the areas
noted (lines referenced in the review: 72–77 and 98–101) with concise one-line
comments that explain intent/context only; locate the comments inside the
src/components/ui/SlashParamHint.tsx file near the SlashParamHint component and
its helper functions and trim them to project-style "why" comments or delete if
the code is self-explanatory.

In `@src/lib/base64.ts`:
- Around line 17-18: Replace the manual loop that builds the byte array from the
string by using Uint8Array.from for a cleaner idiomatic conversion: instead of
creating bytes = new Uint8Array(bin.length) and iterating with charCodeAt, call
Uint8Array.from(bin, ch => ch.charCodeAt(0)) (using the existing variables `bin`
and `bytes`) so the byte array is constructed in one expression and the explicit
for-loop can be removed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 50a90e4f-e8f9-49d6-ba62-51c3c564a081

📥 Commits

Reviewing files that changed from the base of the PR and between 2257f1c and 9546ee9.

📒 Files selected for processing (67)
  • src/components/layout/ChatArea.tsx
  • src/components/layout/ChatHeader.tsx
  • src/components/message/AiToolsMessagePill.tsx
  • src/components/message/AiToolsPlaceholderBody.tsx
  • src/components/message/BotInvocationChip.tsx
  • src/components/message/MessageItem.tsx
  • src/components/ui/AiToolsCard.tsx
  • src/components/ui/AiToolsHistoryButton.tsx
  • src/components/ui/AiToolsTray.tsx
  • src/components/ui/BotsModal.tsx
  • src/components/ui/SlashCommandParamModal.tsx
  • src/components/ui/SlashCommandPopover.tsx
  • src/components/ui/SlashParamHint.tsx
  • src/hooks/useMessageSending.ts
  • src/lib/aiTools.ts
  • src/lib/base64.ts
  • src/lib/clientCommands.ts
  • src/lib/countries.ts
  • src/lib/irc/IRCClient.ts
  • src/locales/cs/messages.mjs
  • src/locales/cs/messages.po
  • src/locales/de/messages.mjs
  • src/locales/de/messages.po
  • src/locales/en/messages.mjs
  • src/locales/en/messages.po
  • src/locales/es/messages.mjs
  • src/locales/es/messages.po
  • src/locales/fi/messages.mjs
  • src/locales/fi/messages.po
  • src/locales/fr/messages.mjs
  • src/locales/fr/messages.po
  • src/locales/it/messages.mjs
  • src/locales/it/messages.po
  • src/locales/ja/messages.mjs
  • src/locales/ja/messages.po
  • src/locales/ko/messages.mjs
  • src/locales/ko/messages.po
  • src/locales/nl/messages.mjs
  • src/locales/nl/messages.po
  • src/locales/pl/messages.mjs
  • src/locales/pl/messages.po
  • src/locales/pt/messages.mjs
  • src/locales/pt/messages.po
  • src/locales/ro/messages.mjs
  • src/locales/ro/messages.po
  • src/locales/ru/messages.mjs
  • src/locales/ru/messages.po
  • src/locales/sv/messages.mjs
  • src/locales/sv/messages.po
  • src/locales/tr/messages.mjs
  • src/locales/tr/messages.po
  • src/locales/uk/messages.mjs
  • src/locales/uk/messages.po
  • src/locales/zh-TW/messages.mjs
  • src/locales/zh-TW/messages.po
  • src/locales/zh/messages.mjs
  • src/locales/zh/messages.po
  • src/store/handlers/aiTools.ts
  • src/store/handlers/index.ts
  • src/store/handlers/pushbot.ts
  • src/store/index.ts
  • src/types/index.ts
  • tests/components/SlashParamHint.test.ts
  • tests/components/layout/ChatHeader.memberButton.test.tsx
  • tests/lib/aiTools.test.ts
  • tests/store/aiTools.test.ts
  • tests/store/pushbot.test.ts

Comment thread src/components/layout/ChatArea.tsx
Comment on lines +35 to +41
function stepLabel(step: AiStep): string {
if (step.label) return step.label;
if (step.type === "tool-call" || step.type === "tool-result")
return step.tool ?? step.type;
if (step.type === "reasoning") return "Reasoning";
return "Text";
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Internationalize the hardcoded step-label strings.

Lines 39-40 return hardcoded English strings "Reasoning" and "Text" that will be displayed to users. These should be localized. Since stepLabel is a helper function, consider either accepting a translator function as a parameter or moving the logic inline where useLingui() is available.

🌐 Proposed fix

One approach is to move the logic into the component where useLingui() is available:

-function stepLabel(step: AiStep): string {
-  if (step.label) return step.label;
-  if (step.type === "tool-call" || step.type === "tool-result")
-    return step.tool ?? step.type;
-  if (step.type === "reasoning") return "Reasoning";
-  return "Text";
-}
-
 export const AiToolsPlaceholderBody: React.FC<AiToolsPlaceholderBodyProps> = ({
   serverId,
   workflowId,
 }) => {
+  const { t } = useLingui();
   const workflow = useStore((s) => s.aiWorkflows[serverId]?.[workflowId]);
+  
+  const getStepLabel = (step: AiStep): string => {
+    if (step.label) return step.label;
+    if (step.type === "tool-call" || step.type === "tool-result")
+      return step.tool ?? step.type;
+    if (step.type === "reasoning") return t`Reasoning`;
+    return t`Text`;
+  };
 
   // ... later in render:
-          {stepLabel(lastStep)}
+          {getStepLabel(lastStep)}

Import statement:

-import { Trans } from "`@lingui/react/macro`";
+import { Trans, useLingui } from "`@lingui/react/macro`";

As per coding guidelines, never use t macro at module scope — move localized strings inside function bodies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/message/AiToolsPlaceholderBody.tsx` around lines 35 - 41, The
helper function stepLabel(AiStep) returns hardcoded English labels ("Reasoning"
and "Text"); to localize, move translation into a context where useLingui() is
available (e.g., inside the component that calls stepLabel) or change stepLabel
to accept a translator function (e.g., pass in i18n._ or t) and replace the
hardcoded returns with translated strings; ensure you do not call the t macro at
module scope—invoke the translator inside a function body (e.g., component
render or stepLabel when given the translator) and update all call sites
accordingly.

Comment thread src/components/message/BotInvocationChip.tsx
Comment thread src/components/ui/BotToolsCard.tsx
Comment thread src/components/ui/AiToolsCard.tsx Outdated
Comment thread src/components/ui/SlashCommandPopover.tsx Outdated
Comment thread src/components/ui/SlashParamHint.tsx
Comment thread src/hooks/useMessageSending.ts Outdated
Comment thread src/lib/botTools.ts
Comment thread src/locales/fi/messages.po Outdated
sendBotCommand defaulted a command with no `contexts` to ["public"], which made
private commands (e.g. /report) get sent as a public TAGMSG to the channel when
their schema arrived via the obby.world/bot-info directory (which carries the
legacy visibility/scopes, not contexts). resolveContexts now falls back to
visibility/scopes so a private/dm command is routed privately even before the
directory emits the spec `contexts`.
Previously, IRC ERROR lines from the server were detected (handleError
logs + isRateLimitError matches /throttled/i), rateLimited fired, but
nothing in the React store or any component subscribed to the event.
Non-throttle ERROR lines were swallowed entirely after a console.log.

Now:
 - handleError also triggers a generic 'serverError' event for any ERROR
   that isn't a rate-limit, so the message reaches the store layer.
 - Store subscribes to both 'rateLimited' (warn) and 'serverError' (fail)
   and surfaces them via addGlobalNotification, picking up the existing
   GlobalNotifications.tsx toast UI.
socket.onerror was rejecting with a generic "Failed to connect to host:port"
that swallowed the underlying transport error (Tauri native-tls handshake
failures, ConnectionRefused, etc.). Web WebSocket emits a typed event that's
roughly as opaque, but Tauri's invoke("connect") returns a real error string
("TLS handshake failed: ...", "Failed to connect: ...") that was being
thrown away.

Preserve the cause in the rejection AND fire a serverError event so the
notification subscriber surfaces a toast — symmetric with how IRC-protocol
ERROR lines are already handled.
Tauri's TCP/TLS connect path had no timeout, so a stalled handshake to
e.g. irc.mirc.club waited on kernel retransmits for ~2 min with no UI
feedback. Add 15s timeouts to both the TCP connect and the TLS
handshake; the timeout error string flows through socket.onerror, then
through serverError, then into a global notification toast.

Also add a raw IRC log viewer (Ctrl+Shift+L) that captures every TX/RX
line plus connect/error info lines into a 2000-line ring buffer per
server. Modal shows the current server's log with copy-all + clear.

Android manifest: declare CAMERA/RECORD_AUDIO/MODIFY_AUDIO_SETTINGS
plus optional camera/microphone hardware features so Tauri's
RustWebChromeClient's runtime permission launcher actually has perms
to grant.
…rser

Two compounding bugs put mirc.club on ircs://irc.mirc.club:443 instead
of :6697 (which is nginx's HTTPS port, hence the HTTP 400 reply):

1. AddServerModal stripped the scheme from the host string before
   appending the user-entered port, but did NOT strip the embedded
   :port / path. The discovery prefill passes host="ircs://h:6697",
   port="6697", so we built ircs://h:6697:6697 — a triple-colon URL.

2. ircUrlParser's fallback (for malformed URLs the regex can't parse)
   used the WebSocket defaults — 443 for ircs:// and 8000 for irc://
   — instead of the IRC standard ports 6697 / 6667. So the malformed
   triple-colon URL hit the fallback and silently became port 443.

Strip everything after the scheme in cleanHost (host part only), and
fix the IRC fallback ports. Existing broken entries auto-recover the
next time the parser falls through.
Conflicts:
- src/components/ui/AddServerModal.tsx: kept this branch's more thorough
  scheme + port/path strip (regex covers irc:// + path tails, fixing the
  port-double bug 2c0cf1a was added to address); main's narrower regex
  was a subset of it.
- src/locales/*/messages.mjs: regenerated from the merged .po files via
  npx lingui compile so they reflect the union of strings.
ValwareIRC pushed a commit that referenced this pull request Jun 4, 2026
Picked the findings that were genuine correctness or HTML-validity
issues; skipped the lingui-extraction nags (those land in a separate
i18n pass via lefthook).

- BotInvocationChip: switch the inline base64 decode to base64DecodeUtf8
  so emoji and non-ASCII bot names round-trip correctly (atob alone is
  Latin-1).

- aiTools.decodeAiToolsValue: reject unknown enum values for workflow
  state, step type, step state, and action type. The function contract
  said schema mismatches return null, but obj.state etc. were cast
  through without checking against the type unions.

- ChatArea slash-suggestion fanout: bot scope filter looked up botNick
  in a lowercased chanUsers set without lowercasing the bot's own nick,
  so mixed-case bot nicks always reported "not in channel". Also let
  channel-scoped bots leak into PM context (selectedChannel was used to
  bypass the membership check); change to require a channel + membership.
  Both 2410-block and the 2543-block.

- BotsModal search filter: guard b.realname with ?? "" so a bot whose
  RPL_WHOIS lacked the realname field doesn't blow up on toLowerCase.

- useMessageSending.sendBotCommand: only dispatch the PM fallback when
  the command actually declares "pm" in contexts. The old final else
  fired even for context-restricted commands, sending an invocation the
  bot would reject anyway.

- SlashCommandPopover: the popover is position:fixed so
  getBoundingClientRect().left is already viewport-relative; the extra
  + window.scrollX shifted the anchor on scrolled pages.

- AiToolsCard header: the outer was a <button> and contained nested
  Dismiss/Cancel <button>s, which is invalid HTML and reportedly breaks
  focus/click on some platforms. Convert the outer to a <div
  role="button"> with keyboard handler; the inner buttons stay native.
  Biome a11y/useSemanticElements is suppressed at that one spot since
  using a <button> there is exactly what we're avoiding.

- AiToolsCard countdown effect: drop the Zustand action ref `dismiss`
  from the deps and apply the project's standard biome-ignore for
  useExhaustiveDependencies (per CLAUDE.md).
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 4, 2026

Pages Preview
Preview URL: https://feat-bot-tools-spec.obsidianirc.pages.dev

Automated deployment preview for the PR in the Cloudflare Pages.

@ValwareIRC ValwareIRC requested a review from matheusfillipe June 4, 2026 19:11
Picked the findings that were genuine correctness or HTML-validity
issues; skipped the lingui-extraction nags (those land in a separate
i18n pass via lefthook).

- BotInvocationChip: switch the inline base64 decode to base64DecodeUtf8
  so emoji and non-ASCII bot names round-trip correctly (atob alone is
  Latin-1).

- aiTools.decodeAiToolsValue: reject unknown enum values for workflow
  state, step type, step state, and action type. The function contract
  said schema mismatches return null, but obj.state etc. were cast
  through without checking against the type unions.

- ChatArea slash-suggestion fanout: bot scope filter looked up botNick
  in a lowercased chanUsers set without lowercasing the bot's own nick,
  so mixed-case bot nicks always reported "not in channel". Also let
  channel-scoped bots leak into PM context (selectedChannel was used to
  bypass the membership check); change to require a channel + membership.
  Both 2410-block and the 2543-block.

- BotsModal search filter: guard b.realname with ?? "" so a bot whose
  RPL_WHOIS lacked the realname field doesn't blow up on toLowerCase.

- useMessageSending.sendBotCommand: only dispatch the PM fallback when
  the command actually declares "pm" in contexts. The old final else
  fired even for context-restricted commands, sending an invocation the
  bot would reject anyway.

- SlashCommandPopover: the popover is position:fixed so
  getBoundingClientRect().left is already viewport-relative; the extra
  + window.scrollX shifted the anchor on scrolled pages.

- AiToolsCard header: the outer was a <button> and contained nested
  Dismiss/Cancel <button>s, which is invalid HTML and reportedly breaks
  focus/click on some platforms. Convert the outer to a <div
  role="button"> with keyboard handler; the inner buttons stay native.
  Biome a11y/useSemanticElements is suppressed at that one spot since
  using a <button> there is exactly what we're avoiding.

- AiToolsCard countdown effect: drop the Zustand action ref `dismiss`
  from the deps and apply the project's standard biome-ignore for
  useExhaustiveDependencies (per CLAUDE.md).
@ValwareIRC ValwareIRC force-pushed the feat/bot-tools-spec branch from cb04983 to 20de3c8 Compare June 4, 2026 19:13
Comment thread src-tauri/plugins/unifiedpush/Cargo.lock Outdated
Comment thread src/components/layout/ChatArea.tsx
Comment thread src/components/message/BotToolsMessagePill.tsx
Comment thread src/lib/clientCommands.ts Outdated
….lock

Matt flagged the build artifacts under android/build/ (lint XML referring
to local paths, kotlin compile-cache binaries, manifest-merger logs) and
the plugin's Cargo.lock.

Remove 148 files (147 from android/build/, 1 Cargo.lock) and add a
.gitignore so `tauri android build` doesn't re-track them. Cargo.lock is
omitted per Rust library convention (apps commit lockfiles, libraries
don't). android/.tauri/ stays tracked to match the ios-keyboard plugin
precedent — those are auto-generated tauri-api stubs, not build outputs.
* Rename AiTools -> BotTools across the codebase. The wire format is
  draft/bot-tools and matt rightly pointed out the internal naming was
  out of sync with the spec. 9 files renamed (src/lib/{aiTools->botTools}
  .ts, src/store/handlers/{aiTools->botTools}.ts, the five Ai* components,
  both Ai* test files) plus 66+ symbol references (AiTools->BotTools,
  aiTools->botTools, AI_TOOLS_->BOT_TOOLS_) updated transitively. Wire-
  format strings ("+draft/bot-tools", "draft/bot-tools") stay literal.

* i18n the client slash-command labels in clientCommands.ts. Module-
  scope `t` is unsafe (catalogue isn't activated at import time --
  CLAUDE.md), so the CLIENT_COMMANDS const becomes a getClientCommands()
  function that re-evaluates each call. CLIENT_COMMAND_NAMES stays a
  locale-independent Set. 13 new strings translated across all 18
  non-English locales in parallel.

* SlashCommandPopover: refuse to render until inputRect has a real
  position. Previously the first frame after `isVisible` flipped true
  could land before the inputElement ref had resolved, so the popover
  rendered at the (100, 100) fallback anchor for one frame -- a visible
  flash up-and-left of the input that matt asked about.

The unifiedpush build-artifact cleanup landed in the previous commit.
Matt's right that this doesn't belong here. The whole
src-tauri/plugins/unifiedpush/ tree got picked up by accident in
fc50a0f ('desktop: surface connect failures...'); my working tree had
the parallel feature/webpush branch's scaffolding sitting in the same
src-tauri/plugins/ directory and `git add -A` swept it in.

Nothing on this branch actually wires the plugin in -- no references
in src-tauri/src/, Cargo.toml, tauri.conf.json, or anywhere in src/.
It's orphan scaffolding. The plugin lives on feature/webpush (PR #236)
where it's actually built into the app.

Removed all 30 remaining files (the previous chore commit only got
android/build/ + Cargo.lock, 148 files; this clears the rest -- the
android/.tauri/ tauri-api stubs and the empty plugin shell).

Build + 845 tests still green; nothing on this branch was depending on it.
Resolutions:
- src/lib/irc/IRCClient.ts: both branches added a new CAP REQ entry
  in the same neighbourhood (HEAD added "draft/bot-cmds" +
  "obby.world/channel-bots"; main added "obby.world/invitation").
  Keep all three -- they're independent vendor caps with no overlap.
- src/locales/*.po: took ours then re-extracted to fold in main's new
  msgids. Backfilled all 23 invitation-panel strings × 18 locales from
  obbyworld/main:src/locales/{loc}/messages.po by copying msgstrs
  verbatim (they were translated in PR #231 already). .mjs recompiled.

Build + 858 tests green.
Two CodeRabbit findings, both still valid against the merged tree:

* SlashParamHint left four user-visible literals raw in JSX:
  "via @${botNick}", "(handled by ObsidianIRC)", "required", "one of:".
  Wrap each with <Trans>, route the bot-nick through Trans's
  interpolation so the placeholder reaches the catalogue. Skipped the
  "string" type literal (it's a schema-data identifier, not prose) and
  the option name/description spans (those are bot-supplied strings,
  not translatable from this side).

* fi/messages.po had "offline" -> "offline" (English fallback) on the
  BotsModal status badge. Change to "ei verkossa".

Re-extract pulled in the 4 new strings; translated all 18 non-English
locales inline. .mjs recompiled, 858 tests + build green.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/lib/botTools.ts (2)

140-143: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate features against the allowed workflow feature set.

typeof f === "string" still accepts unsupported values and then casts them to AiWorkflowFeature[], so decodeBotToolsValue() can return impossible feature flags even though this decoder is supposed to reject schema mismatches. Filter against a WORKFLOW_FEATURES set here, or return null if you want strict rejection.

[suggested fix]

Diff
+const WORKFLOW_FEATURES: ReadonlySet<AiWorkflowFeature> = new Set([
+  "interactive",
+  "reasoning",
+  "approval",
+]);
+
 ...
-      if (Array.isArray(obj.features))
-        m.features = obj.features.filter(
-          (f): f is AiWorkflowFeature => typeof f === "string",
-        ) as AiWorkflowFeature[];
+      if (Array.isArray(obj.features)) {
+        const features = obj.features.filter(
+          (f): f is AiWorkflowFeature =>
+            typeof f === "string" &&
+            WORKFLOW_FEATURES.has(f as AiWorkflowFeature),
+        );
+        if (features.length !== obj.features.length) return null;
+        m.features = features;
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/botTools.ts` around lines 140 - 143, The current filter in
decodeBotToolsValue that sets m.features from obj.features only checks typeof f
=== "string" and then casts to AiWorkflowFeature[], allowing unsupported feature
strings; update the filtering to only accept values present in the canonical set
(e.g., WORKFLOW_FEATURES) by checking membership before casting, or if you
prefer strict decoding, return null from decodeBotToolsValue when any
obj.features entry is not in WORKFLOW_FEATURES; target the m.features assignment
and use AiWorkflowFeature and WORKFLOW_FEATURES symbols to implement the
membership check or early null-return.

150-166: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject tool-call / tool-result frames that omit tool.

countableSteps() pairs tool frames by tool, but the decoder currently accepts tool events without a tool name. That lets malformed payloads into state and can undercount by treating undefined === undefined as a match. Require obj.tool when type is "tool-call" or "tool-result".

Diff
       if (
         typeof obj.wid !== "string" ||
         typeof obj.sid !== "string" ||
         typeof obj.type !== "string" ||
         typeof obj.state !== "string" ||
         !STEP_TYPES.has(obj.type as AiStepType) ||
         !STEP_STATES.has(obj.state as AiStepState)
       )
         return null;
+      if (
+        (obj.type === "tool-call" || obj.type === "tool-result") &&
+        typeof obj.tool !== "string"
+      ) {
+        return null;
+      }
       const m: AiStepMessage = {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/botTools.ts` around lines 150 - 166, The decoder that constructs an
AiStepMessage currently allows frames with type "tool-call" or "tool-result" to
omit obj.tool, which breaks countableSteps() pairing; update the validation in
the decoder (the block that checks typeof obj.wid/sid/type/state and builds
AiStepMessage) to additionally require typeof obj.tool === "string" and reject
(return null) when obj.type is "tool-call" or "tool-result" and obj.tool is
missing or not a string, and continue to set m.tool = obj.tool only after that
check.
🧹 Nitpick comments (1)
src/lib/clientCommands.ts (1)

10-14: ⚡ Quick win

Derive CLIENT_COMMAND_NAMES from the same source as getClientCommands().

Lines 10-14 now understate the update path: the manual CLIENT_COMMAND_NAMES set adds another sync point beyond the handler branch. If those lists drift, the popover/hint paths that treat getClientCommands() as canonical will diverge from any CLIENT_COMMAND_NAMES consumer. Prefer one locale-independent source of truth for names/scope and derive both exports from it.

Also applies to: 124-134

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/clientCommands.ts` around lines 10 - 14, Replace the duplicated
manual name list with a single source of truth: keep the canonical array/object
of client command descriptors (the data used by getClientCommands()) and derive
CLIENT_COMMAND_NAMES by mapping that canonical list to its name strings (and
similarly derive any other name-only exports around lines 124-134 from the same
canonical list); update getClientCommands() to return or filter that canonical
descriptor list rather than relying on a separately-maintained set, and remove
the hard-coded CLIENT_COMMAND_NAMES so names and scopes always come from the
same locale-independent source.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/layout/ChatArea.tsx`:
- Around line 951-969: The block that calls fetchUploadInfo(...) and mints
tokens (the tokens array, fetchUploadInfo, ircClient.requestToken and
waitForAuthToken inside the for loop) can throw before per-file try/catch runs;
wrap the entire initialization (the if block guarded by !tokenlessEndpoint &&
filehostUrl) in a try/catch so initialization failures are caught and handled
immediately: on error log it (or surface to the UI) and return early without
starting any per-file jobs, ensuring no unhandled rejection escapes; keep the
existing per-file logic unchanged but rely on the caught initialization error to
prevent further processing.

In `@src/components/ui/AddServerModal.tsx`:
- Around line 119-121: The current host-cleaning logic in AddServerModal
(cleanHost derived from serverHost) uses .replace(/[:/].*$/) which breaks IPv6
addresses; replace that logic by attempting to construct a URL from serverHost
and use url.hostname (which preserves IPv6 bracket notation and removes
port/path), and only fall back to the simple regex-based cleanup if URL parsing
throws; update the code that computes cleanHost in the AddServerModal component
to use this URL-parsing-first approach so ports/paths are stripped correctly
while IPv6 addresses like ::1 or [::1] remain intact.

In `@src/locales/fr/messages.po`:
- Around line 2727-2730: The French translation for the msgid "This server
doesn't support invite links (the<0>obby.world/invitation</0>capability isn't
advertised)..." has no spaces around the inline tag; update the msgstr so there
is a space before the opening tag and a space after the closing tag (i.e.,
change "...la capacité<0>obby.world/invitation</0>n'est..." to "...la capacité
<0>obby.world/invitation</0> n'est...") so the rendered UI text is not
concatenated.

---

Outside diff comments:
In `@src/lib/botTools.ts`:
- Around line 140-143: The current filter in decodeBotToolsValue that sets
m.features from obj.features only checks typeof f === "string" and then casts to
AiWorkflowFeature[], allowing unsupported feature strings; update the filtering
to only accept values present in the canonical set (e.g., WORKFLOW_FEATURES) by
checking membership before casting, or if you prefer strict decoding, return
null from decodeBotToolsValue when any obj.features entry is not in
WORKFLOW_FEATURES; target the m.features assignment and use AiWorkflowFeature
and WORKFLOW_FEATURES symbols to implement the membership check or early
null-return.
- Around line 150-166: The decoder that constructs an AiStepMessage currently
allows frames with type "tool-call" or "tool-result" to omit obj.tool, which
breaks countableSteps() pairing; update the validation in the decoder (the block
that checks typeof obj.wid/sid/type/state and builds AiStepMessage) to
additionally require typeof obj.tool === "string" and reject (return null) when
obj.type is "tool-call" or "tool-result" and obj.tool is missing or not a
string, and continue to set m.tool = obj.tool only after that check.

---

Nitpick comments:
In `@src/lib/clientCommands.ts`:
- Around line 10-14: Replace the duplicated manual name list with a single
source of truth: keep the canonical array/object of client command descriptors
(the data used by getClientCommands()) and derive CLIENT_COMMAND_NAMES by
mapping that canonical list to its name strings (and similarly derive any other
name-only exports around lines 124-134 from the same canonical list); update
getClientCommands() to return or filter that canonical descriptor list rather
than relying on a separately-maintained set, and remove the hard-coded
CLIENT_COMMAND_NAMES so names and scopes always come from the same
locale-independent source.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99b137ca-58ff-4e85-bbfe-8f61545765ac

📥 Commits

Reviewing files that changed from the base of the PR and between 0f162e9 and 4b23819.

📒 Files selected for processing (61)
  • src/components/layout/ChatArea.tsx
  • src/components/layout/ChatHeader.tsx
  • src/components/message/BotInvocationChip.tsx
  • src/components/message/BotToolsMessagePill.tsx
  • src/components/message/BotToolsPlaceholderBody.tsx
  • src/components/message/MessageItem.tsx
  • src/components/ui/AddServerModal.tsx
  • src/components/ui/BotToolsCard.tsx
  • src/components/ui/BotToolsHistoryButton.tsx
  • src/components/ui/BotToolsTray.tsx
  • src/components/ui/BotsModal.tsx
  • src/components/ui/SlashCommandPopover.tsx
  • src/components/ui/SlashParamHint.tsx
  • src/hooks/useMessageSending.ts
  • src/lib/botTools.ts
  • src/lib/clientCommands.ts
  • src/lib/irc/IRCClient.ts
  • src/locales/cs/messages.mjs
  • src/locales/cs/messages.po
  • src/locales/de/messages.mjs
  • src/locales/de/messages.po
  • src/locales/en/messages.mjs
  • src/locales/en/messages.po
  • src/locales/es/messages.mjs
  • src/locales/es/messages.po
  • src/locales/fi/messages.mjs
  • src/locales/fi/messages.po
  • src/locales/fr/messages.mjs
  • src/locales/fr/messages.po
  • src/locales/it/messages.mjs
  • src/locales/it/messages.po
  • src/locales/ja/messages.mjs
  • src/locales/ja/messages.po
  • src/locales/ko/messages.mjs
  • src/locales/ko/messages.po
  • src/locales/nl/messages.mjs
  • src/locales/nl/messages.po
  • src/locales/pl/messages.mjs
  • src/locales/pl/messages.po
  • src/locales/pt/messages.mjs
  • src/locales/pt/messages.po
  • src/locales/ro/messages.mjs
  • src/locales/ro/messages.po
  • src/locales/ru/messages.mjs
  • src/locales/ru/messages.po
  • src/locales/sv/messages.mjs
  • src/locales/sv/messages.po
  • src/locales/tr/messages.mjs
  • src/locales/tr/messages.po
  • src/locales/uk/messages.mjs
  • src/locales/uk/messages.po
  • src/locales/zh-TW/messages.mjs
  • src/locales/zh-TW/messages.po
  • src/locales/zh/messages.mjs
  • src/locales/zh/messages.po
  • src/store/handlers/botTools.ts
  • src/store/handlers/index.ts
  • src/store/index.ts
  • src/types/index.ts
  • tests/lib/botTools.test.ts
  • tests/store/botTools.test.ts
✅ Files skipped from review due to trivial changes (8)
  • src/locales/cs/messages.mjs
  • src/locales/es/messages.mjs
  • src/locales/es/messages.po
  • src/locales/it/messages.po
  • src/locales/fi/messages.po
  • src/locales/en/messages.po
  • src/locales/cs/messages.po
  • src/locales/de/messages.po
🚧 Files skipped from review as they are similar to previous changes (11)
  • src/lib/irc/IRCClient.ts
  • src/locales/en/messages.mjs
  • src/locales/de/messages.mjs
  • src/locales/it/messages.mjs
  • src/components/message/BotInvocationChip.tsx
  • src/locales/fr/messages.mjs
  • src/components/ui/SlashCommandPopover.tsx
  • src/locales/fi/messages.mjs
  • src/components/ui/BotsModal.tsx
  • src/components/ui/SlashParamHint.tsx
  • src/hooks/useMessageSending.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/lib/botTools.ts (2)

140-143: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate features against the allowed workflow feature set.

typeof f === "string" still accepts unsupported values and then casts them to AiWorkflowFeature[], so decodeBotToolsValue() can return impossible feature flags even though this decoder is supposed to reject schema mismatches. Filter against a WORKFLOW_FEATURES set here, or return null if you want strict rejection.

[suggested fix]

Diff
+const WORKFLOW_FEATURES: ReadonlySet<AiWorkflowFeature> = new Set([
+  "interactive",
+  "reasoning",
+  "approval",
+]);
+
 ...
-      if (Array.isArray(obj.features))
-        m.features = obj.features.filter(
-          (f): f is AiWorkflowFeature => typeof f === "string",
-        ) as AiWorkflowFeature[];
+      if (Array.isArray(obj.features)) {
+        const features = obj.features.filter(
+          (f): f is AiWorkflowFeature =>
+            typeof f === "string" &&
+            WORKFLOW_FEATURES.has(f as AiWorkflowFeature),
+        );
+        if (features.length !== obj.features.length) return null;
+        m.features = features;
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/botTools.ts` around lines 140 - 143, The current filter in
decodeBotToolsValue that sets m.features from obj.features only checks typeof f
=== "string" and then casts to AiWorkflowFeature[], allowing unsupported feature
strings; update the filtering to only accept values present in the canonical set
(e.g., WORKFLOW_FEATURES) by checking membership before casting, or if you
prefer strict decoding, return null from decodeBotToolsValue when any
obj.features entry is not in WORKFLOW_FEATURES; target the m.features assignment
and use AiWorkflowFeature and WORKFLOW_FEATURES symbols to implement the
membership check or early null-return.

150-166: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject tool-call / tool-result frames that omit tool.

countableSteps() pairs tool frames by tool, but the decoder currently accepts tool events without a tool name. That lets malformed payloads into state and can undercount by treating undefined === undefined as a match. Require obj.tool when type is "tool-call" or "tool-result".

Diff
       if (
         typeof obj.wid !== "string" ||
         typeof obj.sid !== "string" ||
         typeof obj.type !== "string" ||
         typeof obj.state !== "string" ||
         !STEP_TYPES.has(obj.type as AiStepType) ||
         !STEP_STATES.has(obj.state as AiStepState)
       )
         return null;
+      if (
+        (obj.type === "tool-call" || obj.type === "tool-result") &&
+        typeof obj.tool !== "string"
+      ) {
+        return null;
+      }
       const m: AiStepMessage = {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/botTools.ts` around lines 150 - 166, The decoder that constructs an
AiStepMessage currently allows frames with type "tool-call" or "tool-result" to
omit obj.tool, which breaks countableSteps() pairing; update the validation in
the decoder (the block that checks typeof obj.wid/sid/type/state and builds
AiStepMessage) to additionally require typeof obj.tool === "string" and reject
(return null) when obj.type is "tool-call" or "tool-result" and obj.tool is
missing or not a string, and continue to set m.tool = obj.tool only after that
check.
🧹 Nitpick comments (1)
src/lib/clientCommands.ts (1)

10-14: ⚡ Quick win

Derive CLIENT_COMMAND_NAMES from the same source as getClientCommands().

Lines 10-14 now understate the update path: the manual CLIENT_COMMAND_NAMES set adds another sync point beyond the handler branch. If those lists drift, the popover/hint paths that treat getClientCommands() as canonical will diverge from any CLIENT_COMMAND_NAMES consumer. Prefer one locale-independent source of truth for names/scope and derive both exports from it.

Also applies to: 124-134

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/clientCommands.ts` around lines 10 - 14, Replace the duplicated
manual name list with a single source of truth: keep the canonical array/object
of client command descriptors (the data used by getClientCommands()) and derive
CLIENT_COMMAND_NAMES by mapping that canonical list to its name strings (and
similarly derive any other name-only exports around lines 124-134 from the same
canonical list); update getClientCommands() to return or filter that canonical
descriptor list rather than relying on a separately-maintained set, and remove
the hard-coded CLIENT_COMMAND_NAMES so names and scopes always come from the
same locale-independent source.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/layout/ChatArea.tsx`:
- Around line 951-969: The block that calls fetchUploadInfo(...) and mints
tokens (the tokens array, fetchUploadInfo, ircClient.requestToken and
waitForAuthToken inside the for loop) can throw before per-file try/catch runs;
wrap the entire initialization (the if block guarded by !tokenlessEndpoint &&
filehostUrl) in a try/catch so initialization failures are caught and handled
immediately: on error log it (or surface to the UI) and return early without
starting any per-file jobs, ensuring no unhandled rejection escapes; keep the
existing per-file logic unchanged but rely on the caught initialization error to
prevent further processing.

In `@src/components/ui/AddServerModal.tsx`:
- Around line 119-121: The current host-cleaning logic in AddServerModal
(cleanHost derived from serverHost) uses .replace(/[:/].*$/) which breaks IPv6
addresses; replace that logic by attempting to construct a URL from serverHost
and use url.hostname (which preserves IPv6 bracket notation and removes
port/path), and only fall back to the simple regex-based cleanup if URL parsing
throws; update the code that computes cleanHost in the AddServerModal component
to use this URL-parsing-first approach so ports/paths are stripped correctly
while IPv6 addresses like ::1 or [::1] remain intact.

In `@src/locales/fr/messages.po`:
- Around line 2727-2730: The French translation for the msgid "This server
doesn't support invite links (the<0>obby.world/invitation</0>capability isn't
advertised)..." has no spaces around the inline tag; update the msgstr so there
is a space before the opening tag and a space after the closing tag (i.e.,
change "...la capacité<0>obby.world/invitation</0>n'est..." to "...la capacité
<0>obby.world/invitation</0> n'est...") so the rendered UI text is not
concatenated.

---

Outside diff comments:
In `@src/lib/botTools.ts`:
- Around line 140-143: The current filter in decodeBotToolsValue that sets
m.features from obj.features only checks typeof f === "string" and then casts to
AiWorkflowFeature[], allowing unsupported feature strings; update the filtering
to only accept values present in the canonical set (e.g., WORKFLOW_FEATURES) by
checking membership before casting, or if you prefer strict decoding, return
null from decodeBotToolsValue when any obj.features entry is not in
WORKFLOW_FEATURES; target the m.features assignment and use AiWorkflowFeature
and WORKFLOW_FEATURES symbols to implement the membership check or early
null-return.
- Around line 150-166: The decoder that constructs an AiStepMessage currently
allows frames with type "tool-call" or "tool-result" to omit obj.tool, which
breaks countableSteps() pairing; update the validation in the decoder (the block
that checks typeof obj.wid/sid/type/state and builds AiStepMessage) to
additionally require typeof obj.tool === "string" and reject (return null) when
obj.type is "tool-call" or "tool-result" and obj.tool is missing or not a
string, and continue to set m.tool = obj.tool only after that check.

---

Nitpick comments:
In `@src/lib/clientCommands.ts`:
- Around line 10-14: Replace the duplicated manual name list with a single
source of truth: keep the canonical array/object of client command descriptors
(the data used by getClientCommands()) and derive CLIENT_COMMAND_NAMES by
mapping that canonical list to its name strings (and similarly derive any other
name-only exports around lines 124-134 from the same canonical list); update
getClientCommands() to return or filter that canonical descriptor list rather
than relying on a separately-maintained set, and remove the hard-coded
CLIENT_COMMAND_NAMES so names and scopes always come from the same
locale-independent source.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99b137ca-58ff-4e85-bbfe-8f61545765ac

📥 Commits

Reviewing files that changed from the base of the PR and between 0f162e9 and 4b23819.

📒 Files selected for processing (61)
  • src/components/layout/ChatArea.tsx
  • src/components/layout/ChatHeader.tsx
  • src/components/message/BotInvocationChip.tsx
  • src/components/message/BotToolsMessagePill.tsx
  • src/components/message/BotToolsPlaceholderBody.tsx
  • src/components/message/MessageItem.tsx
  • src/components/ui/AddServerModal.tsx
  • src/components/ui/BotToolsCard.tsx
  • src/components/ui/BotToolsHistoryButton.tsx
  • src/components/ui/BotToolsTray.tsx
  • src/components/ui/BotsModal.tsx
  • src/components/ui/SlashCommandPopover.tsx
  • src/components/ui/SlashParamHint.tsx
  • src/hooks/useMessageSending.ts
  • src/lib/botTools.ts
  • src/lib/clientCommands.ts
  • src/lib/irc/IRCClient.ts
  • src/locales/cs/messages.mjs
  • src/locales/cs/messages.po
  • src/locales/de/messages.mjs
  • src/locales/de/messages.po
  • src/locales/en/messages.mjs
  • src/locales/en/messages.po
  • src/locales/es/messages.mjs
  • src/locales/es/messages.po
  • src/locales/fi/messages.mjs
  • src/locales/fi/messages.po
  • src/locales/fr/messages.mjs
  • src/locales/fr/messages.po
  • src/locales/it/messages.mjs
  • src/locales/it/messages.po
  • src/locales/ja/messages.mjs
  • src/locales/ja/messages.po
  • src/locales/ko/messages.mjs
  • src/locales/ko/messages.po
  • src/locales/nl/messages.mjs
  • src/locales/nl/messages.po
  • src/locales/pl/messages.mjs
  • src/locales/pl/messages.po
  • src/locales/pt/messages.mjs
  • src/locales/pt/messages.po
  • src/locales/ro/messages.mjs
  • src/locales/ro/messages.po
  • src/locales/ru/messages.mjs
  • src/locales/ru/messages.po
  • src/locales/sv/messages.mjs
  • src/locales/sv/messages.po
  • src/locales/tr/messages.mjs
  • src/locales/tr/messages.po
  • src/locales/uk/messages.mjs
  • src/locales/uk/messages.po
  • src/locales/zh-TW/messages.mjs
  • src/locales/zh-TW/messages.po
  • src/locales/zh/messages.mjs
  • src/locales/zh/messages.po
  • src/store/handlers/botTools.ts
  • src/store/handlers/index.ts
  • src/store/index.ts
  • src/types/index.ts
  • tests/lib/botTools.test.ts
  • tests/store/botTools.test.ts
✅ Files skipped from review due to trivial changes (8)
  • src/locales/cs/messages.mjs
  • src/locales/es/messages.mjs
  • src/locales/es/messages.po
  • src/locales/it/messages.po
  • src/locales/fi/messages.po
  • src/locales/en/messages.po
  • src/locales/cs/messages.po
  • src/locales/de/messages.po
🚧 Files skipped from review as they are similar to previous changes (11)
  • src/lib/irc/IRCClient.ts
  • src/locales/en/messages.mjs
  • src/locales/de/messages.mjs
  • src/locales/it/messages.mjs
  • src/components/message/BotInvocationChip.tsx
  • src/locales/fr/messages.mjs
  • src/components/ui/SlashCommandPopover.tsx
  • src/locales/fi/messages.mjs
  • src/components/ui/BotsModal.tsx
  • src/components/ui/SlashParamHint.tsx
  • src/hooks/useMessageSending.ts
🛑 Comments failed to post (3)
src/components/layout/ChatArea.tsx (1)

951-969: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Catch upload-initialization failures before starting jobs.

fetchUploadInfo / token minting can throw before per-file upload try/catch runs. Right now that failure can escape UI handlers as an unhandled rejection and fail silently for users.

🛠️ Proposed fix
-    if (!tokenlessEndpoint && filehostUrl) {
-      info = await fetchUploadInfo(filehostUrl);
-      // Serialised because waitForAuthToken resolves on the first matching
-      // TOKEN_GENERATE event -- parallel mints would race for the reply.
-      const scope = target.startsWith("#") ? `channel:${target}` : undefined;
-      for (let i = 0; i < files.length; i++) {
-        ircClient.requestToken(selectedServerId, "filehost", scope);
-        const tok = await waitForAuthToken(selectedServerId, "filehost");
-        if (!tok) {
-          console.error(
-            "draft/authtoken: server did not return a filehost token",
-          );
-          return;
-        }
-        tokens.push(tok);
-      }
-    }
+    try {
+      if (!tokenlessEndpoint && filehostUrl) {
+        info = await fetchUploadInfo(filehostUrl);
+        // Serialised because waitForAuthToken resolves on the first matching
+        // TOKEN_GENERATE event -- parallel mints would race for the reply.
+        const scope = target.startsWith("#") ? `channel:${target}` : undefined;
+        for (let i = 0; i < files.length; i++) {
+          ircClient.requestToken(selectedServerId, "filehost", scope);
+          const tok = await waitForAuthToken(selectedServerId, "filehost");
+          if (!tok) {
+            console.error(
+              "draft/authtoken: server did not return a filehost token",
+            );
+            return;
+          }
+          tokens.push(tok);
+        }
+      }
+    } catch (err) {
+      console.error("File upload initialization failed:", err);
+      return;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    let info: Awaited<ReturnType<typeof fetchUploadInfo>> = null;
    const tokens: string[] = [];
    try {
      if (!tokenlessEndpoint && filehostUrl) {
        info = await fetchUploadInfo(filehostUrl);
        // Serialised because waitForAuthToken resolves on the first matching
        // TOKEN_GENERATE event -- parallel mints would race for the reply.
        const scope = target.startsWith("#") ? `channel:${target}` : undefined;
        for (let i = 0; i < files.length; i++) {
          ircClient.requestToken(selectedServerId, "filehost", scope);
          const tok = await waitForAuthToken(selectedServerId, "filehost");
          if (!tok) {
            console.error(
              "draft/authtoken: server did not return a filehost token",
            );
            return;
          }
          tokens.push(tok);
        }
      }
    } catch (err) {
      console.error("File upload initialization failed:", err);
      return;
    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/ChatArea.tsx` around lines 951 - 969, The block that
calls fetchUploadInfo(...) and mints tokens (the tokens array, fetchUploadInfo,
ircClient.requestToken and waitForAuthToken inside the for loop) can throw
before per-file try/catch runs; wrap the entire initialization (the if block
guarded by !tokenlessEndpoint && filehostUrl) in a try/catch so initialization
failures are caught and handled immediately: on error log it (or surface to the
UI) and return early without starting any per-file jobs, ensuring no unhandled
rejection escapes; keep the existing per-file logic unchanged but rely on the
caught initialization error to prevent further processing.
src/components/ui/AddServerModal.tsx (1)

119-121: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

IPv6 address parsing is broken by the regex.

The regex /[:/].*$/ strips everything from the first : or /, which breaks IPv6 addresses:

  • [::1][ (colon inside brackets is matched)
  • ::1: (first colon is matched)
  • [2001:db8::1][2001

This will cause connection failures if users enter IPv6 addresses (e.g., localhost ::1 for development).

🔧 Proposed fix using URL parsing

Use URL parsing to extract the hostname, which correctly handles IPv6 bracket notation:

       const port = Number.parseInt(serverPort, 10);
-      // Strip scheme AND any embedded :port / path so we don't end up
-      // appending port twice (e.g. ircs://host:6697:6697).
-      const cleanHost = serverHost
-        .replace(/^(https?|wss?|ircs?|irc):\/\//, "")
-        .replace(/[:/].*$/, "");
+      // Strip scheme and extract hostname (handles IPv6, ports, and paths)
+      let cleanHost = serverHost.replace(/^(https?|wss?|ircs?|irc):\/\//, "");
+      try {
+        // Use URL parsing to extract hostname (handles IPv6 brackets)
+        const url = new URL(`http://${cleanHost}`);
+        cleanHost = url.hostname;
+      } catch {
+        // Fall back to regex for plain hostnames without special chars
+        cleanHost = cleanHost.replace(/[:/].*$/, "");
+      }
       finalHost = useWebSocket
         ? `wss://${cleanHost}:${port}`
         : `ircs://${cleanHost}:${port}`;

This approach:

  • Uses URL.hostname which correctly extracts IPv6 addresses from bracket notation
  • Falls back to the existing regex for simple hostnames if URL parsing fails
  • Preserves the intent to strip ports and paths while fixing IPv6 support
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        const port = Number.parseInt(serverPort, 10);
        // Strip scheme and extract hostname (handles IPv6, ports, and paths)
        let cleanHost = serverHost.replace(/^(https?|wss?|ircs?|irc):\/\//, "");
        try {
          // Use URL parsing to extract hostname (handles IPv6 brackets)
          const url = new URL(`http://${cleanHost}`);
          cleanHost = url.hostname;
        } catch {
          // Fall back to regex for plain hostnames without special chars
          cleanHost = cleanHost.replace(/[:/].*$/, "");
        }
        finalHost = useWebSocket
          ? `wss://${cleanHost}:${port}`
          : `ircs://${cleanHost}:${port}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/AddServerModal.tsx` around lines 119 - 121, The current
host-cleaning logic in AddServerModal (cleanHost derived from serverHost) uses
.replace(/[:/].*$/) which breaks IPv6 addresses; replace that logic by
attempting to construct a URL from serverHost and use url.hostname (which
preserves IPv6 bracket notation and removes port/path), and only fall back to
the simple regex-based cleanup if URL parsing throws; update the code that
computes cleanHost in the AddServerModal component to use this URL-parsing-first
approach so ports/paths are stripped correctly while IPv6 addresses like ::1 or
[::1] remain intact.
src/locales/fr/messages.po (1)

2727-2730: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add spaces around the inline capability tag in the French translation.

Line 2729 currently concatenates words around <0>...</0>, which will render awkwardly in UI text.

💡 Suggested fix
-msgstr "Ce serveur ne prend pas en charge les liens d'invitation (la capacité<0>obby.world/invitation</0>n'est pas annoncée). Vous pouvez toujours discuter normalement ; ce panneau est destiné aux réseaux propulsés par obbyircd."
+msgstr "Ce serveur ne prend pas en charge les liens d'invitation (la capacité <0>obby.world/invitation</0> n'est pas annoncée). Vous pouvez toujours discuter normalement ; ce panneau est destiné aux réseaux propulsés par obbyircd."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/locales/fr/messages.po` around lines 2727 - 2730, The French translation
for the msgid "This server doesn't support invite links
(the<0>obby.world/invitation</0>capability isn't advertised)..." has no spaces
around the inline tag; update the msgstr so there is a space before the opening
tag and a space after the closing tag (i.e., change "...la
capacité<0>obby.world/invitation</0>n'est..." to "...la capacité
<0>obby.world/invitation</0> n'est...") so the rendered UI text is not
concatenated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants