Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a1ff18f
feat(ai-tools): draft/ai-tools workflow viewer
ValwareIRC May 14, 2026
ecb260f
feat(ai-tools): recursive badge renderer for structured step content
ValwareIRC May 14, 2026
5308c8d
feat(ai-tools): two-way deep link between card and final PRIVMSG
ValwareIRC May 14, 2026
02d9308
feat(ai-tools): workflow history button in chat header
ValwareIRC May 14, 2026
6b5d656
i18n(ai-tools): translate workflow history button + step count
ValwareIRC May 14, 2026
407316e
feat(ai-tools): consistent project-diagram icon, inline pill
ValwareIRC May 14, 2026
5a1a013
fix(ai-tools): float pill left so it sits inline with first line of body
ValwareIRC May 14, 2026
f0a7731
feat(ai-tools): card auto-dismiss countdown, header offset, auto-scroll
ValwareIRC May 14, 2026
9dd4fe5
fix(ai-tools): pill clickable + vertical connector between step dots
ValwareIRC May 14, 2026
188574f
feat(ai-tools): show prompt under bot nick on workflow card
ValwareIRC May 14, 2026
e3e20bf
feat(ai-tools): drop card auto-dismiss to 5s, fade across full window
ValwareIRC May 14, 2026
432817f
feat(ai-tools): show count badge next to workflow history icon
ValwareIRC May 14, 2026
9a40315
i18n(ai-tools): translate 'Workflow history ({0})' tooltip
ValwareIRC May 14, 2026
b57f2cf
feat(ai-tools): mute the inline workflow pill
ValwareIRC May 14, 2026
5b7662c
feat(ai-tools): redesign workflow surface as in-chat placeholder
ValwareIRC May 14, 2026
92331cb
fix(ai-tools): preserve reply context on morph + clean up stuck place…
ValwareIRC May 14, 2026
2adde50
fix(ai-tools): allow clicking inline pill to view historical workflows
ValwareIRC May 14, 2026
49a297b
feat(ai-tools): hang inline pill in avatar gutter instead of inline
ValwareIRC May 14, 2026
52e9dc4
feat(ai-tools): pair tool-call+result as one IN/OUT row, drop thinkin…
ValwareIRC May 15, 2026
2235432
feat(ai-tools): double the workflow card width
ValwareIRC May 15, 2026
69dc47e
fix(ai-tools): scroll workflow card body to bottom on open
ValwareIRC May 15, 2026
19c9597
fix(ai-tools): track scroll stickiness via onScroll, not post-update …
ValwareIRC May 15, 2026
a76ed4f
client: implement draft/bot-cmds discovery + +draft/bot-cmd invocation
ValwareIRC May 16, 2026
9f34253
client: auto-query bot slash commands on WHO completion
ValwareIRC May 16, 2026
be8a07b
client: surface PushBot commands in the slash autocomplete
ValwareIRC May 16, 2026
e49a94c
client: rich slash-cmd popover + per-arg hint footer
ValwareIRC May 16, 2026
9b312fb
client: show client + server commands in slash popover with badges
ValwareIRC May 16, 2026
f81cb89
client: obby.world/channel-bots cap + Bots management modal
ValwareIRC May 16, 2026
ff95511
client: rebuild BotsModal to match UserSettings layout + drop emoji i…
ValwareIRC May 16, 2026
a19dfd9
slash picker: show server-scope bots, prefer bot names over server cm…
ValwareIRC May 16, 2026
c3f9047
slash picker: multi-bot same-name + correct anchor + brand highlight
ValwareIRC May 16, 2026
ad537a3
slash picker: switch server-bot badge from purple to sky
ValwareIRC May 16, 2026
a7ca7f7
slash picker: open a typed param-form modal on select
ValwareIRC May 16, 2026
7b6aa8b
slash commands: reactive hint + invocation attribution on bot replies
ValwareIRC May 17, 2026
0a6c7d6
BotsModal: click commands to invoke; filter by reachability
ValwareIRC May 17, 2026
6179cf3
Merge branch 'draft-ai-tools' into feat/bot-tools-spec
ValwareIRC May 24, 2026
9546ee9
bot-tools/bot-cmds: rework client to the draft/ IRCv3 Bot Tools spec
ValwareIRC May 24, 2026
0f162e9
bot-cmds: never route a private command publicly (privacy)
ValwareIRC May 24, 2026
713b356
irc/connection: surface ERROR + rateLimited as global notifications
ValwareIRC May 30, 2026
9106f61
ircclient: surface transport-level connect failures via serverError
ValwareIRC May 30, 2026
fc50a0f
desktop: surface connect failures via timeouts + a raw-log viewer
ValwareIRC May 30, 2026
2c0cf1a
desktop: fix port-double in AddServerModal + wrong IRC defaults in pa…
ValwareIRC May 30, 2026
ffbddb3
Merge branch 'main' into feat/bot-tools-spec
ValwareIRC Jun 4, 2026
20de3c8
bot-tools: address CodeRabbit findings on PR #238
ValwareIRC Jun 4, 2026
ea1d6b6
chore(tauri/unifiedpush): drop committed Gradle build outputs + Cargo…
ValwareIRC Jun 5, 2026
0ab49cb
bot-tools: address matt's review on PR #238
ValwareIRC Jun 5, 2026
049a66d
chore(tauri): drop the unifiedpush plugin from this branch entirely
ValwareIRC Jun 5, 2026
40d3fd2
Merge branch 'main' into feat/bot-tools-spec
ValwareIRC Jun 5, 2026
4b23819
bot-tools: i18n SlashParamHint literals + fi 'offline'
ValwareIRC Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 239 additions & 10 deletions src/components/layout/ChatArea.tsx
Comment thread
ValwareIRC marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isScrolledToBottom } from "../../hooks/useScrollToBottom";
import { useTabCompletion } from "../../hooks/useTabCompletion";
import { useTypingNotification } from "../../hooks/useTypingNotification";
import { waitForAuthToken } from "../../lib/authToken";
import { getClientCommands } from "../../lib/clientCommands";
import { useEmojiResolver } from "../../lib/customEmoji";
import {
emojiClickValue,
Expand All @@ -37,11 +38,14 @@ import {
} from "../../lib/messageFormatter";
import { isMobileDevice, isTauriMobile } from "../../lib/platformUtils";
import useStore from "../../store";
import type { Message as MessageType, User } from "../../types";
import { queryUncachedBotsInChannel } from "../../store/handlers/pushbot";
import type { BotCommand, Message as MessageType, User } from "../../types";
import { MessageItem } from "../message/MessageItem";
import { MessageReply } from "../message/MessageReply";
import AutocompleteDropdown from "../ui/AutocompleteDropdown";
import BlankPage from "../ui/BlankPage";
import BotsModal from "../ui/BotsModal";
import { BotToolsTray } from "../ui/BotToolsTray";
import ChannelSettingsModal from "../ui/ChannelSettingsModal";
import ColorPicker from "../ui/ColorPicker";
import EmojiAutocompleteDropdown from "../ui/EmojiAutocompleteDropdown";
Expand All @@ -56,10 +60,13 @@ import { MiniMediaPlayer } from "../ui/MiniMediaPlayer";
import ModerationModal, { type ModerationAction } from "../ui/ModerationModal";
import ReactionModal from "../ui/ReactionModal";
import { ReactionPopover } from "../ui/ReactionPopover";
import { SlashCommandParamModal } from "../ui/SlashCommandParamModal";
import {
getActiveSlashQuery,
SlashCommandPopover,
type SlashSuggestion,
} from "../ui/SlashCommandPopover";
import { SlashParamHint } from "../ui/SlashParamHint";
import { TextArea } from "../ui/TextInput";
import { TopicMediaStrip } from "../ui/TopicMediaStrip";
import {
Expand Down Expand Up @@ -141,6 +148,10 @@ export const ChatArea: React.FC<{
// with "/" and we're still completing the command name -- otherwise
// typing wouldn't trigger a re-render for this popover at all.
const [slashInputValue, setSlashInputValue] = useState("");
const [paramModal, setParamModal] = useState<{
botNick: string;
command: BotCommand;
} | null>(null);
const [isEmojiSelectorOpen, setIsEmojiSelectorOpen] = useState(false);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [selectedColor, setSelectedColor] = useState<string | null>(null);
Expand Down Expand Up @@ -224,6 +235,7 @@ export const ChatArea: React.FC<{
useState(false);
const [userProfileModalOpen, setUserProfileModalOpen] = useState(false);
const [inviteUserModalOpen, setInviteUserModalOpen] = useState(false);
const [botsModalOpen, setBotsModalOpen] = useState(false);
const [selectedProfileUsername, setSelectedProfileUsername] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const inputRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -1438,6 +1450,13 @@ export const ChatArea: React.FC<{
getActiveSlashQuery(newText, newCursorPosition) !== null;
if (slashActive) {
setSlashInputValue(newText);
// Lazy bot-cmds discovery: the first time the user starts a
// slash command in this channel, query any +B users we don't
// already have schemas for so the popover updates as soon as
// the response arrives.
if (!slashInputValue && selectedServerId && selectedChannel) {
queryUncachedBotsInChannel(selectedServerId, selectedChannel.name);
}
} else if (slashInputValue !== "") {
setSlashInputValue("");
}
Expand Down Expand Up @@ -1988,6 +2007,7 @@ export const ChatArea: React.FC<{
onToggleNotificationVolume={handleToggleNotificationVolume}
onOpenChannelSettings={() => setChannelSettingsModalOpen(true)}
onOpenInviteUser={() => setInviteUserModalOpen(true)}
onOpenBots={() => setBotsModalOpen(true)}
/>

{topSlot && (
Expand Down Expand Up @@ -2024,8 +2044,14 @@ export const ChatArea: React.FC<{
Embedded iframes (YouTube) are suspended by the browser, but we
explicitly stop embed media on channel switch (see effect below). */}
<div
className={`flex flex-col min-h-0 ${channelKey ? "flex-grow" : ""}`}
className={`relative flex flex-col min-h-0 ${channelKey ? "flex-grow" : ""}`}
>
<BotToolsTray
serverId={selectedServerId}
channel={
selectedChannel?.name ?? selectedPrivateChat?.username ?? null
}
/>
{aliveChannels.map(
({
key,
Expand Down Expand Up @@ -2313,11 +2339,84 @@ export const ChatArea: React.FC<{
inputElement={inputRef.current}
/>

{/* obsidianirc/cmdslist: slash-command suggestion popover */}
{/* obsidianirc/cmdslist + draft/bot-cmds: slash-command suggestion popover */}
{(() => {
const srv = servers.find((s) => s.id === selectedServerId);
const cmds = srv?.cmdsAvailable ?? [];
if (cmds.length === 0) return null;
const suggestions: SlashSuggestion[] = [];
const seen = new Set<string>();

// 1. Client-side handlers (e.g. /me, /msg, /nick) —
// listed first because they own those names and the
// server's cmdslist may shadow them.
const inChannelView = !!selectedChannel;
for (const c of getClientCommands()) {
if (c.scope === "channel-only" && !inChannelView) continue;
suggestions.push({
name: c.name,
description: c.description,
options: c.options,
source: { kind: "client" },
});
seen.add(c.name.toLowerCase());
}

// 2. PushBot schemas — done before the server's
// cmdsAvailable so that bot-defined names win the
// dedup set. Channel-scope bots only appear when
// they're a member of the active channel; server-
// scope bots (helpbot, dicebot) are reachable from
// any context and always show.
if (srv?.botCommands) {
const chanUsers = new Set<string>(
selectedChannel?.users.map((u) =>
u.username.toLowerCase(),
) ?? [],
);
for (const [botNick, list] of Object.entries(
srv.botCommands,
)) {
const botScope: "channel" | "server" =
srv.bots?.[botNick]?.scope ?? "channel";
const inChannel = chanUsers.has(botNick.toLowerCase());
if (
botScope === "channel" &&
(!selectedChannel || !inChannel)
)
continue;
const scope: "channel" | "server" = botScope;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (const c of list) {
// Cross-bot collisions intentionally NOT dedup'd:
// both /help entries (helpbot + another) should
// show so the user picks which bot to invoke.
// The picker fills `/cmd@botnick` so dispatch
// routes unambiguously.
suggestions.push({
name: c.name,
description: c.description,
options: c.options,
source: { kind: "bot", botNick, scope },
});
// Reserve the bare name so the server's
// cmdsAvailable can't list a duplicate /HELP
// after a bot's /help.
seen.add(c.name.toLowerCase());
}
}
}

// 3. Server-provided permission list (obsidianirc/cmdslist).
// No schema -- just names; skip anything the client
// or a bot already owns so /help, /report etc. don't
// get shadowed by the built-in /HELP.
for (const name of srv?.cmdsAvailable ?? []) {
if (seen.has(name.toLowerCase())) continue;
suggestions.push({
name,
source: { kind: "server" },
});
seen.add(name.toLowerCase());
}
if (suggestions.length === 0) return null;
const slashActive =
getActiveSlashQuery(
slashInputValue,
Expand All @@ -2327,12 +2426,36 @@ export const ChatArea: React.FC<{
<SlashCommandPopover
isVisible={slashActive}
inputValue={slashInputValue}
commands={cmds}
commands={suggestions}
inputElement={inputRef.current}
onSelect={(cmd) => {
// Replace the partial command with /<cmd> + space
// and put the cursor right after the space.
const next = `/${cmd} `;
onSelect={(sug) => {
// Bot commands with declared options open the
// param modal so values are entered via typed
// form controls (user/channel pickers, date,
// etc). Bots without options and non-bot
// sources stay on the freeform inline path.
if (
sug.source.kind === "bot" &&
(sug.options?.length ?? 0) > 0
) {
const botNick = sug.source.botNick;
setParamModal({
botNick,
command: {
name: sug.name,
description: sug.description,
options: sug.options,
},
});
setSlashInputValue("");
applyText("");
return;
}
const target =
sug.source.kind === "bot"
? `@${sug.source.botNick}`
: "";
const next = `/${sug.name}${target} `;
applyText(next);
cursorPositionRef.current = next.length;
setSlashInputValue("");
Expand All @@ -2349,6 +2472,83 @@ export const ChatArea: React.FC<{
);
})()}

{/* Per-arg hint shown after the cmd name + a space.
Pulls schemas from both client-side commands and
any cached PushBot schemas. */}
{(() => {
const srv = servers.find((s) => s.id === selectedServerId);
const schemas: Record<
string,
{
command: import("../../types").BotCommand;
source: "client" | "bot";
botNick?: string;
scope: "channel" | "server" | "dm";
}
> = {};

// Client-side commands first; bot schemas overwrite
// only if there's a real collision (none in practice
// because the client owns /me, /msg, etc).
for (const c of getClientCommands()) {
schemas[c.name.toLowerCase()] = {
command: {
name: c.name,
description: c.description,
options: c.options,
},
source: "client",
scope: "dm",
};
}

if (srv?.botCommands) {
const chanUsers = new Set<string>(
selectedChannel?.users.map((u) =>
u.username.toLowerCase(),
) ?? [],
);
for (const [botNick, list] of Object.entries(
srv.botCommands,
)) {
const botScope: "channel" | "server" =
srv.bots?.[botNick]?.scope ?? "channel";
const inChannel = chanUsers.has(botNick.toLowerCase());
if (
botScope === "channel" &&
(!selectedChannel || !inChannel)
)
continue;
for (const c of list) {
const entry = {
command: c,
source: "bot" as const,
botNick,
scope: botScope,
};
// Specific key handles `/cmd@bot foo` lookups;
// bare key is a fallback for `/cmd foo` (last
// bot wins, which matches dispatch's fallback
// ordering).
schemas[
`${c.name.toLowerCase()}@${botNick.toLowerCase()}`
] = entry;
schemas[c.name.toLowerCase()] = entry;
}
}
}

if (Object.keys(schemas).length === 0) return null;
return (
<SlashParamHint
inputValue={messageTextRef.current ?? ""}
cursorPosition={cursorPositionRef.current}
schemas={schemas}
inputElement={inputRef.current}
/>
);
})()}

{/* Members dropdown triggered by @ button */}
<AutocompleteDropdown
isNarrowView={isNarrowView}
Expand Down Expand Up @@ -2446,6 +2646,16 @@ export const ChatArea: React.FC<{
channelName={selectedChannel.name}
/>
)}
{selectedServerId && (
<BotsModal
isOpen={botsModalOpen}
onClose={() => setBotsModalOpen(false)}
serverId={selectedServerId}
onPickCommand={(botNick, command) => {
setParamModal({ botNick, command });
}}
/>
)}
{selectedServerId && (
<UserProfileModal
isOpen={userProfileModalOpen}
Expand Down Expand Up @@ -2581,6 +2791,25 @@ export const ChatArea: React.FC<{
</div>,
document.body,
)}
{paramModal &&
selectedServerId &&
createPortal(
<SlashCommandParamModal
serverId={selectedServerId}
botNick={paramModal.botNick}
command={paramModal.command}
channel={selectedChannel ?? null}
privateChat={selectedPrivateChat ?? null}
channelMembers={selectedChannel?.users.map((u) => u.username) ?? []}
joinedChannels={
servers
.find((s) => s.id === selectedServerId)
?.channels.map((c) => c.name) ?? []
}
onClose={() => setParamModal(null)}
/>,
document.body,
)}
</div>
);
};
Expand Down
Loading
Loading