-
Notifications
You must be signed in to change notification settings - Fork 7
Chat Phase 2a — desktop admin UI + live signal (settings panel, context menu, slash menu, typing/thinking) #235
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b542ea7
cb5e00e
08b200f
6b2e7de
7e5a04e
6a0b8b2
3e86960
e3b8e86
8f22506
851613b
404d147
3589c43
62c14a3
60c1eeb
cfc91c4
5835c02
7c18349
37e581f
81fbac3
f97038e
c4f0dac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import React, { useEffect, useRef } from "react"; | ||
| import { muteAgent, unmuteAgent, removeChannelMember } from "@/lib/channel-admin-api"; | ||
|
|
||
| export type AgentContextMenuProps = { | ||
| slug: string; | ||
| channelId?: string; | ||
| channelType?: string; | ||
| isMuted?: boolean; | ||
| x: number; | ||
| y: number; | ||
| onClose: () => void; | ||
| onDm?: (slug: string) => void; | ||
| onViewInfo?: (slug: string) => void; | ||
| onJumpToSettings?: (slug: string) => void; | ||
| }; | ||
|
|
||
| export function AgentContextMenu({ | ||
| slug, channelId, channelType, isMuted, | ||
| x, y, onClose, onDm, onViewInfo, onJumpToSettings, | ||
| }: AgentContextMenuProps) { | ||
| const ref = useRef<HTMLDivElement>(null); | ||
|
|
||
| useEffect(() => { | ||
| const onDocClick = (e: MouseEvent) => { | ||
| if (ref.current && !ref.current.contains(e.target as Node)) onClose(); | ||
| }; | ||
| const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; | ||
| document.addEventListener("mousedown", onDocClick); | ||
| document.addEventListener("keydown", onKey); | ||
| return () => { | ||
| document.removeEventListener("mousedown", onDocClick); | ||
| document.removeEventListener("keydown", onKey); | ||
| }; | ||
| }, [onClose]); | ||
|
|
||
| const isDm = channelType === "dm"; | ||
|
|
||
| const doMute = async () => { | ||
| if (!channelId) return; | ||
| try { | ||
| if (isMuted) await unmuteAgent(channelId, slug); | ||
| else await muteAgent(channelId, slug); | ||
| } finally { onClose(); } | ||
| }; | ||
| const doRemove = async () => { | ||
| if (!channelId) return; | ||
| try { await removeChannelMember(channelId, slug); } finally { onClose(); } | ||
| }; | ||
|
|
||
| return ( | ||
| <div | ||
| ref={ref} | ||
| role="menu" | ||
| aria-label={`Actions for @${slug}`} | ||
| className="fixed z-50 min-w-[200px] bg-shell-surface border border-white/10 rounded-lg shadow-xl py-1 text-sm" | ||
| style={{ top: y, left: x }} | ||
| > | ||
| <MenuItem onClick={() => { onDm?.(slug); onClose(); }}>DM @{slug}</MenuItem> | ||
| {channelId && !isDm && ( | ||
| <MenuItem onClick={doMute}> | ||
| {isMuted ? "Unmute" : "Mute"} in this channel | ||
| </MenuItem> | ||
| )} | ||
| {channelId && !isDm && ( | ||
| <MenuItem onClick={doRemove}>Remove from channel</MenuItem> | ||
| )} | ||
| <div className="my-1 h-px bg-white/10" /> | ||
| <MenuItem onClick={() => { onViewInfo?.(slug); onClose(); }}>View agent info</MenuItem> | ||
| <MenuItem onClick={() => { onJumpToSettings?.(slug); onClose(); }}>Jump to agent settings</MenuItem> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function MenuItem({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { | ||
| return ( | ||
| <button | ||
| role="menuitem" | ||
| onClick={onClick} | ||
| className="w-full text-left px-3 py-1.5 hover:bg-white/5 focus:bg-white/5 focus:outline-none" | ||
| > | ||
| {children} | ||
| </button> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| import { useEffect, useState } from "react"; | ||
| import { | ||
| patchChannel, addChannelMember, removeChannelMember, muteAgent, unmuteAgent, | ||
| } from "@/lib/channel-admin-api"; | ||
|
|
||
| type Channel = { | ||
| id: string; | ||
| name: string; | ||
| type: "dm" | "group" | "topic"; | ||
| topic: string; | ||
| members: string[]; | ||
| settings: { | ||
| response_mode?: "quiet" | "lively"; | ||
| max_hops?: number; | ||
| cooldown_seconds?: number; | ||
| muted?: string[]; | ||
| }; | ||
| }; | ||
|
|
||
| type KnownAgent = { name: string }; | ||
|
|
||
| export function ChannelSettingsPanel({ | ||
| channel, knownAgents, onClose, onChanged, | ||
| }: { | ||
| channel: Channel; | ||
| knownAgents: KnownAgent[]; | ||
| onClose: () => void; | ||
| onChanged: () => void; | ||
| }) { | ||
| const [name, setName] = useState(channel.name); | ||
| const [topic, setTopic] = useState(channel.topic || ""); | ||
| const [mode, setMode] = useState(channel.settings.response_mode ?? "quiet"); | ||
| const [hops, setHops] = useState(channel.settings.max_hops ?? 3); | ||
| const [cooldown, setCooldown] = useState(channel.settings.cooldown_seconds ?? 5); | ||
| const [err, setErr] = useState<string | null>(null); | ||
|
|
||
| // Keep local state in sync if the parent pushes an updated channel | ||
| useEffect(() => { | ||
| setName(channel.name); | ||
| setTopic(channel.topic || ""); | ||
| setMode(channel.settings.response_mode ?? "quiet"); | ||
| setHops(channel.settings.max_hops ?? 3); | ||
| setCooldown(channel.settings.cooldown_seconds ?? 5); | ||
| }, [channel]); | ||
|
|
||
| const apply = async (patch: Parameters<typeof patchChannel>[1], rollback: () => void) => { | ||
| setErr(null); | ||
| try { await patchChannel(channel.id, patch); onChanged(); } | ||
| catch (e) { rollback(); setErr(e instanceof Error ? e.message : "failed"); } | ||
| }; | ||
|
|
||
| const members = channel.members || []; | ||
| const muted = channel.settings.muted || []; | ||
| const candidateAdds = knownAgents | ||
| .map((a) => a.name) | ||
| .filter((s) => !members.includes(s)); | ||
| const candidateMutes = members.filter((m) => m !== "user" && !muted.includes(m)); | ||
|
|
||
| return ( | ||
| <aside | ||
| role="complementary" | ||
| aria-label="Channel settings" | ||
| className="fixed top-0 right-0 h-full w-[360px] bg-shell-surface border-l border-white/10 shadow-xl flex flex-col z-40" | ||
| > | ||
| <header className="flex items-center justify-between px-4 py-3 border-b border-white/10"> | ||
| <h2 className="text-sm font-semibold">Channel settings</h2> | ||
| <button onClick={onClose} aria-label="Close" className="text-lg leading-none">×</button> | ||
| </header> | ||
|
|
||
| <div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-5 text-sm"> | ||
| <section aria-label="Overview" className="flex flex-col gap-3"> | ||
| <h3 className="text-xs uppercase tracking-wider text-shell-text-tertiary">Overview</h3> | ||
| <label className="flex flex-col gap-1"> | ||
| <span className="text-xs text-shell-text-secondary">Name</span> | ||
| <input | ||
| value={name} | ||
| maxLength={100} | ||
| onChange={(e) => setName(e.target.value)} | ||
| onBlur={() => name !== channel.name && apply({ name }, () => setName(channel.name))} | ||
| className="bg-white/5 border border-white/10 rounded px-2 py-1.5 text-sm" | ||
| /> | ||
| </label> | ||
| <label className="flex flex-col gap-1"> | ||
| <span className="text-xs text-shell-text-secondary">Topic</span> | ||
| <textarea | ||
| value={topic} | ||
| maxLength={500} | ||
| rows={3} | ||
| onChange={(e) => setTopic(e.target.value)} | ||
| onBlur={() => topic !== (channel.topic || "") && apply({ topic }, () => setTopic(channel.topic || ""))} | ||
| className="bg-white/5 border border-white/10 rounded px-2 py-1.5 text-sm resize-none" | ||
| /> | ||
| </label> | ||
| <div className="text-[11px] text-shell-text-tertiary"> | ||
| Type: <span className="uppercase tracking-wide">{channel.type}</span> | ||
| </div> | ||
| </section> | ||
|
|
||
| <section aria-label="Members" className="flex flex-col gap-2"> | ||
| <h3 className="text-xs uppercase tracking-wider text-shell-text-tertiary">Members</h3> | ||
| <ul className="flex flex-col gap-1"> | ||
| {members.map((m) => ( | ||
| <li key={m} className="flex items-center justify-between px-2 py-1 rounded hover:bg-white/5"> | ||
| <span>@{m}</span> | ||
| {m !== "user" && ( | ||
| <button | ||
| className="text-xs text-red-300 hover:text-red-200" | ||
| onClick={async () => { | ||
| try { await removeChannelMember(channel.id, m); onChanged(); } | ||
| catch (e) { setErr(e instanceof Error ? e.message : "failed"); } | ||
| }} | ||
| > | ||
| Remove | ||
| </button> | ||
| )} | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| {candidateAdds.length > 0 && ( | ||
| <AddDropdown | ||
| label="Add agent" | ||
| options={candidateAdds} | ||
| onPick={async (slug) => { | ||
| try { await addChannelMember(channel.id, slug); onChanged(); } | ||
| catch (e) { setErr(e instanceof Error ? e.message : "failed"); } | ||
| }} | ||
| /> | ||
| )} | ||
| </section> | ||
|
|
||
| <section aria-label="Moderation" className="flex flex-col gap-3"> | ||
| <h3 className="text-xs uppercase tracking-wider text-shell-text-tertiary">Moderation</h3> | ||
| <div className="flex items-center gap-2"> | ||
| <span className="text-xs text-shell-text-secondary">Mode:</span> | ||
| <button | ||
| className={`px-2 py-1 rounded text-xs ${mode === "quiet" ? "bg-sky-500/30 text-sky-200" : "bg-white/5"}`} | ||
| onClick={() => apply({ response_mode: "quiet" }, () => setMode(mode))} | ||
| >quiet</button> | ||
| <button | ||
| className={`px-2 py-1 rounded text-xs ${mode === "lively" ? "bg-emerald-500/30 text-emerald-200" : "bg-white/5"}`} | ||
| onClick={() => apply({ response_mode: "lively" }, () => setMode(mode))} | ||
| >lively</button> | ||
| </div> | ||
| <div className="flex flex-col gap-1"> | ||
| <span className="text-xs text-shell-text-secondary">Muted</span> | ||
| <div className="flex flex-wrap gap-1"> | ||
| {muted.map((m) => ( | ||
| <span key={m} className="inline-flex items-center gap-1 bg-white/5 rounded px-2 py-0.5 text-xs"> | ||
| @{m} | ||
| <button | ||
| aria-label={`Unmute ${m}`} | ||
| onClick={async () => { try { await unmuteAgent(channel.id, m); onChanged(); } catch (e) { setErr(e instanceof Error ? e.message : "failed"); } }} | ||
| >×</button> | ||
| </span> | ||
| ))} | ||
| {muted.length === 0 && <span className="text-[11px] text-shell-text-tertiary">none</span>} | ||
| </div> | ||
| {candidateMutes.length > 0 && ( | ||
| <AddDropdown | ||
| label="Mute agent" | ||
| options={candidateMutes} | ||
| onPick={async (slug) => { | ||
| try { await muteAgent(channel.id, slug); onChanged(); } | ||
| catch (e) { setErr(e instanceof Error ? e.message : "failed"); } | ||
| }} | ||
| /> | ||
| )} | ||
| </div> | ||
| </section> | ||
|
|
||
| <section aria-label="Advanced" className="flex flex-col gap-3"> | ||
| <h3 className="text-xs uppercase tracking-wider text-shell-text-tertiary">Advanced</h3> | ||
| <label className="flex flex-col gap-1"> | ||
| <span className="text-xs text-shell-text-secondary">Max hops: {hops}</span> | ||
| <input | ||
| type="range" min={1} max={10} value={hops} | ||
| onChange={(e) => setHops(Number(e.target.value))} | ||
| onMouseUp={() => apply({ max_hops: hops }, () => setHops(channel.settings.max_hops ?? 3))} | ||
| /> | ||
|
Comment on lines
+176
to
+179
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Range settings may not persist for keyboard/touch interactions. Saving only on ♿ Suggested fix <input
type="range" min={1} max={10} value={hops}
onChange={(e) => setHops(Number(e.target.value))}
- onMouseUp={() => apply({ max_hops: hops }, () => setHops(channel.settings.max_hops ?? 3))}
+ onPointerUp={() => apply({ max_hops: hops }, () => setHops(channel.settings.max_hops ?? 3))}
+ onBlur={() => apply({ max_hops: hops }, () => setHops(channel.settings.max_hops ?? 3))}
/>
@@
<input
type="range" min={0} max={60} value={cooldown}
onChange={(e) => setCooldown(Number(e.target.value))}
- onMouseUp={() => apply({ cooldown_seconds: cooldown }, () => setCooldown(channel.settings.cooldown_seconds ?? 5))}
+ onPointerUp={() => apply({ cooldown_seconds: cooldown }, () => setCooldown(channel.settings.cooldown_seconds ?? 5))}
+ onBlur={() => apply({ cooldown_seconds: cooldown }, () => setCooldown(channel.settings.cooldown_seconds ?? 5))}
/>Also applies to: 184-187 🤖 Prompt for AI Agents |
||
| </label> | ||
| <label className="flex flex-col gap-1"> | ||
| <span className="text-xs text-shell-text-secondary">Cooldown: {cooldown}s</span> | ||
| <input | ||
| type="range" min={0} max={60} value={cooldown} | ||
| onChange={(e) => setCooldown(Number(e.target.value))} | ||
| onMouseUp={() => apply({ cooldown_seconds: cooldown }, () => setCooldown(channel.settings.cooldown_seconds ?? 5))} | ||
| /> | ||
| </label> | ||
| </section> | ||
|
|
||
| {err && ( | ||
| <div role="alert" className="text-xs text-red-300 bg-red-500/10 border border-red-500/30 rounded px-2 py-1"> | ||
| {err} | ||
| </div> | ||
| )} | ||
| </div> | ||
| </aside> | ||
| ); | ||
| } | ||
|
|
||
| function AddDropdown({ | ||
| label, options, onPick, | ||
| }: { | ||
| label: string; options: string[]; onPick: (v: string) => void; | ||
| }) { | ||
| return ( | ||
| <select | ||
| aria-label={label} | ||
| defaultValue="" | ||
| onChange={(e) => { if (e.target.value) { onPick(e.target.value); e.target.value = ""; } }} | ||
| className="bg-white/5 border border-white/10 rounded px-2 py-1 text-xs" | ||
| > | ||
| <option value="" disabled>{label}…</option> | ||
| {options.map((s) => <option key={s} value={s}>@{s}</option>)} | ||
| </select> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: jaylfc/tinyagentos
Length of output: 117
🏁 Script executed:
Repository: jaylfc/tinyagentos
Length of output: 2743
Add error handling to mute/remove async handlers.
doMuteanddoRemovecall API functions that throw on failure, but lackcatchblocks. This allows promise rejections to escape from click handlers without user feedback. The menu closes regardless of success or failure, creating poor UX when operations fail.Add an
onErrorcallback prop and catch API errors:💡 Suggested fix
export type AgentContextMenuProps = { slug: string; channelId?: string; channelType?: string; isMuted?: boolean; x: number; y: number; onClose: () => void; + onError?: (message: string) => void; onDm?: (slug: string) => void; onViewInfo?: (slug: string) => void; onJumpToSettings?: (slug: string) => void; }; export function AgentContextMenu({ slug, channelId, channelType, isMuted, - x, y, onClose, onDm, onViewInfo, onJumpToSettings, + x, y, onClose, onError, onDm, onViewInfo, onJumpToSettings, }: AgentContextMenuProps) { @@ const doMute = async () => { if (!channelId) return; try { if (isMuted) await unmuteAgent(channelId, slug); else await muteAgent(channelId, slug); + } catch (e) { + onError?.(e instanceof Error ? e.message : "Failed to update mute state"); } finally { onClose(); } }; const doRemove = async () => { if (!channelId) return; - try { await removeChannelMember(channelId, slug); } finally { onClose(); } + try { + await removeChannelMember(channelId, slug); + } catch (e) { + onError?.(e instanceof Error ? e.message : "Failed to remove member"); + } finally { onClose(); } };🤖 Prompt for AI Agents