Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b542ea7
docs: implementation plan for chat Phase 2a — desktop admin + live si…
jaylfc Apr 19, 2026
cb5e00e
feat(frameworks): add slash_commands manifest field for 6 beta framew…
jaylfc Apr 19, 2026
08b200f
feat(chat): TypingRegistry — per-channel human-typing + agent-thinkin…
jaylfc Apr 19, 2026
6b2e7de
feat(chat): wire TypingRegistry onto app.state
jaylfc Apr 19, 2026
7e5a04e
feat(chat): POST /typing + POST /thinking + GET /typing endpoints
jaylfc Apr 19, 2026
6a0b8b2
feat(frameworks): GET /api/frameworks/slash-commands returns per-agen…
jaylfc Apr 19, 2026
3e86960
feat(bridges): emit thinking heartbeat around LLM call in all 6 bridges
jaylfc Apr 19, 2026
e3b8e86
feat(desktop): channel-admin-api client for Phase 1 REST endpoints
jaylfc Apr 19, 2026
8f22506
feat(desktop): useTypingEmitter hook — debounced /typing POST on comp…
jaylfc Apr 19, 2026
851613b
feat(desktop): TypingFooter component for humans-typing + agents-thin…
jaylfc Apr 19, 2026
404d147
feat(desktop): AgentContextMenu — single right-click hub for agent ac…
jaylfc Apr 19, 2026
3589c43
feat(desktop): ChannelSettingsPanel slide-over with 4 sections
jaylfc Apr 19, 2026
62c14a3
feat(desktop): SlashMenu — unified fuzzy command autocomplete grouped…
jaylfc Apr 19, 2026
60c1eeb
feat(desktop): integrate settings panel, context menu, slash menu, ty…
jaylfc Apr 19, 2026
cfc91c4
fix(desktop): exclude test files from production tsc build
jaylfc Apr 19, 2026
5835c02
build: rebuild desktop bundle for chat Phase 2a
jaylfc Apr 19, 2026
7c18349
test(e2e): chat Phase 2a — slash menu, settings panel, context menu
jaylfc Apr 19, 2026
37e581f
fix(chat-p2a): topic data-loss + agentInfoPopover 'unknown' fields
jaylfc Apr 19, 2026
81fbac3
build: rebuild desktop bundle for chat P2a fixes
jaylfc Apr 19, 2026
f97038e
fix(tests): mock push_file in deployer tests (CI regression from 1269…
jaylfc Apr 19, 2026
c4f0dac
fix(tests): update deployer tests for script-install path precedence
jaylfc Apr 19, 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
751 changes: 751 additions & 0 deletions desktop/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.5.0",
"jsdom": "^29.0.2",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
Expand Down
293 changes: 237 additions & 56 deletions desktop/src/apps/MessagesApp.tsx

Large diffs are not rendered by default.

84 changes: 84 additions & 0 deletions desktop/src/apps/chat/AgentContextMenu.tsx
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(); }
};
Comment on lines +38 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=tsx 'const do(Mute|Remove)\s*=\s*async' -A12 -B2 desktop/src/apps/chat/AgentContextMenu.tsx
rg -nP --type=tsx 'await (muteAgent|unmuteAgent|removeChannelMember)\(' -A4 -B2 desktop/src/apps/chat/AgentContextMenu.tsx

Repository: jaylfc/tinyagentos

Length of output: 117


🏁 Script executed:

cat desktop/src/apps/chat/AgentContextMenu.tsx

Repository: jaylfc/tinyagentos

Length of output: 2743


Add error handling to mute/remove async handlers.

doMute and doRemove call API functions that throw on failure, but lack catch blocks. 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 onError callback 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
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/chat/AgentContextMenu.tsx` around lines 38 - 48, The click
handlers doMute and doRemove call async APIs (muteAgent, unmuteAgent,
removeChannelMember) but lack error handling and always invoke onClose; add an
onError callback prop and wrap the await calls in try/catch so API errors are
caught, call onError(error) inside the catch, and only call onClose after a
successful operation (i.e., move onClose into the try after await, not in
finally). Update doMute and doRemove to reference the new onError prop and
preserve existing early returns when channelId is missing.


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>
);
}
217 changes: 217 additions & 0 deletions desktop/src/apps/chat/ChannelSettingsPanel.tsx
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Range settings may not persist for keyboard/touch interactions.

Saving only on onMouseUp misses non-mouse input paths. Persist on onBlur (and/or onPointerUp) so keyboard users can actually save changes.

♿ 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
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/chat/ChannelSettingsPanel.tsx` around lines 176 - 179, The
range input currently only persists changes onMouseUp which misses keyboard and
touch interactions; update the handler on the hops slider (the input using
value={hops}, setHops and apply) to call apply on onBlur and onPointerUp (or
onTouchEnd) in addition to/onstead of onMouseUp so keyboard and touch users
trigger saving, and keep the same reset callback (() =>
setHops(channel.settings.max_hops ?? 3)) after apply; make the same change for
the other similar range control in this component.

</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>
);
}
Loading
Loading