Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 12 additions & 6 deletions desktop/src/apps/MessagesApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { resolveAgentEmoji } from "@/lib/agent-emoji";
import { ChannelSettingsPanel } from "./chat/ChannelSettingsPanel";
import { AgentContextMenu } from "./chat/AgentContextMenu";
import { SlashMenu, type SlashCommandsBySlug } from "./chat/SlashMenu";
import { TypingFooter } from "./chat/TypingFooter";
import { TypingFooter, type AgentTyping } from "./chat/TypingFooter";
import { useTypingEmitter } from "@/lib/use-typing-emitter";
import { MessageHoverActions } from "./chat/MessageHoverActions";
import { ThreadIndicator } from "./chat/ThreadIndicator";
Expand Down Expand Up @@ -256,7 +256,7 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
>(null);
const [slashCommands, setSlashCommands] = useState<SlashCommandsBySlug>({});
const [typingHumans, setTypingHumans] = useState<string[]>([]);
const [typingAgents, setTypingAgents] = useState<string[]>([]);
const [typingAgents, setTypingAgents] = useState<AgentTyping[]>([]);
const [sendError, setSendError] = useState<string | null>(null);
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
Expand Down Expand Up @@ -387,9 +387,12 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
}
if (data.type === "thinking") {
if (data.state === "start") {
setTypingAgents((prev) => prev.includes(data.slug) ? prev : [...prev, data.slug]);
setTypingAgents((prev) => {
const without = prev.filter((a) => a.slug !== data.slug);
return [...without, { slug: data.slug, phase: data.phase ?? null, detail: data.detail ?? null }];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Stale agent entries may accumulate when multiple start events are received before an end event. The current logic removes only one matching entry, but if duplicates exist they will remain in state.

When multiple start events arrive for the same agent, prev.filter will remove all matching entries, which is correct. However if multiple entries were already present (due to a race condition) they will be properly cleared.

});
} else {
setTypingAgents((prev) => prev.filter((s) => s !== data.slug));
setTypingAgents((prev) => prev.filter((a) => a.slug !== data.slug));
}
return;
}
Expand Down Expand Up @@ -428,9 +431,12 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
// Legacy WS typing (agent only) — route into typingAgents for TypingFooter
// (human typing is handled by the phase-2a branch above)
if ((data.user_type ?? "user") !== "agent") break;
setTypingAgents((prev) => prev.includes(data.user_id) ? prev : [...prev, data.user_id]);
setTypingAgents((prev) => {
const without = prev.filter((a) => a.slug !== data.user_id);
return [...without, { slug: data.user_id, phase: null, detail: null }];
});
setTimeout(() => {
setTypingAgents((prev) => prev.filter((s) => s !== data.user_id));
setTypingAgents((prev) => prev.filter((a) => a.slug !== data.user_id));
}, 5000);
break;

Expand Down
48 changes: 42 additions & 6 deletions desktop/src/apps/chat/TypingFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,22 @@
* Caller feeds in live arrays — they're empty when nothing is active
* and the component renders nothing.
*/

type TypingPhase = "thinking" | "tool" | "reading" | "writing" | "searching" | "planning";

export interface AgentTyping {
slug: string;
phase?: TypingPhase | null;
detail?: string | null;
}

export function TypingFooter({
humans,
agents,
selfId = "user",
}: {
humans: string[];
agents: string[];
agents: AgentTyping[];
selfId?: string;
}) {
const others = humans.filter((h) => h !== selfId);
Expand All @@ -19,15 +28,21 @@ export function TypingFooter({
if (!hasHumans && !hasAgents) return null;

const humanLine = formatHumansTyping(others);
const agentLine = formatAgentsThinking(agents);

return (
<div
aria-live="polite"
className="px-4 pt-1 text-xs text-shell-text-tertiary flex flex-col gap-0.5"
>
{humanLine && <span>{humanLine}</span>}
{agentLine && <span className="italic">{agentLine}</span>}
{agents.map((a) => {
const { icon, text } = phaseLabel(a.phase, a.detail);
return (
<span key={a.slug} className="italic">
{icon} @{a.slug} is {text}…
</span>
);
})}
</div>
);
}
Expand All @@ -39,7 +54,28 @@ function formatHumansTyping(names: string[]): string | null {
return `${names[0]} and ${names.length - 1} others are typing…`;
}

function formatAgentsThinking(slugs: string[]): string | null {
if (slugs.length === 0) return null;
return slugs.map((s) => `${s} is thinking…`).join(" · ");
function phaseLabel(
phase?: TypingPhase | null,
detail?: string | null,
): { icon: string; text: string } {
const d = detail
? detail.length > 40
? detail.slice(0, 39) + "…"
: detail
: null;
switch (phase) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Truncation logic incorrectly counts characters before checking length. The current code creates the truncated string before checking length, so it will always add an ellipsis even when exactly 40 characters.

Suggested change
switch (phase) {
? detail.length > 40 ? detail.slice(0, 40) + "…" : detail

case "tool":
return { icon: "🔧", text: d ? `using ${d}` : "using a tool" };
case "reading":
return { icon: "📖", text: d ? `reading ${d}` : "reading" };
case "writing":
return { icon: "✏️", text: d ? `writing ${d}` : "writing" };
case "searching":
return { icon: "🔍", text: d ? `searching ${d}` : "searching" };
case "planning":
return { icon: "📋", text: "planning" };
case "thinking":
default:
return { icon: "💭", text: "thinking" };
}
}
51 changes: 48 additions & 3 deletions desktop/src/apps/chat/__tests__/TypingFooter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,53 @@ describe("TypingFooter", () => {
const { container } = render(<TypingFooter humans={["user"]} agents={[]} selfId="user" />);
expect(container.firstChild).toBeNull();
});
it("joins multiple agents with middle dot", () => {
render(<TypingFooter humans={[]} agents={["tom", "don"]} />);
expect(screen.getByText("tom is thinking… · don is thinking…")).toBeInTheDocument();
it("shows default thinking label for agent with no phase", () => {
render(<TypingFooter humans={[]} agents={[{ slug: "tom" }]} />);
expect(screen.getByText(/tom/i)).toBeInTheDocument();
expect(screen.getByText(/thinking/i)).toBeInTheDocument();
});
it("renders 'using X' for tool phase", () => {
render(
<TypingFooter
humans={[]}
agents={[{ slug: "tom", phase: "tool", detail: "web_search" }]}
selfId="user"
/>,
);
expect(screen.getByText(/tom/i)).toBeInTheDocument();
expect(screen.getByText(/using web_search/i)).toBeInTheDocument();
});
it("renders 'writing X' for writing phase", () => {
render(
<TypingFooter
humans={[]}
agents={[{ slug: "don", phase: "writing", detail: "payment.py" }]}
selfId="user"
/>,
);
expect(screen.getByText(/writing payment\.py/i)).toBeInTheDocument();
});
it("truncates detail longer than 40 chars", () => {
const longDetail = "a".repeat(60);
render(
<TypingFooter
humans={[]}
agents={[{ slug: "tom", phase: "tool", detail: longDetail }]}
selfId="user"
/>,
);
const text = screen.getByText(/using/i).textContent ?? "";
expect(text.length).toBeLessThanOrEqual(60);
expect(text).toContain("…");
});
it("falls back to 'thinking' for unknown phase", () => {
render(
<TypingFooter
humans={[]}
agents={[{ slug: "tom", phase: "quantum-entanglement" as any, detail: null }]}
selfId="user"
/>,
);
expect(screen.getByText(/thinking/i)).toBeInTheDocument();
});
});
Loading
Loading