@@ -581,7 +461,7 @@ export function ChatView() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
- placeholder="Message (@ to mention an agent)"
+ placeholder="Message (@deuce to bring in the agent)"
rows={1}
className="flex-1 resize-none rounded-md border border-border-muted bg-background-input px-3 py-2 text-sm text-foreground placeholder:text-foreground-subtle focus:border-accent focus:outline-none"
/>
diff --git a/src/components/chat/message-visibility.test.ts b/src/components/chat/message-visibility.test.ts
index abda97c..2ba35e0 100644
--- a/src/components/chat/message-visibility.test.ts
+++ b/src/components/chat/message-visibility.test.ts
@@ -1,13 +1,8 @@
import { describe, expect, it } from "vitest";
import { isVisibleInChat, visibleChatMessages } from "./message-visibility";
+import { DEUCE, SYSTEM_AUTHOR_ID } from "@/lib/deuce";
import type { Message } from "@/types";
-// NOTE: The frontend has no test runner wired up yet (see CLAUDE.md). These
-// Vitest-style specs capture the intended behavior and run as soon as a runner
-// is added. Until then, `npx tsc --noEmit` keeps them type-checked.
-
-const NIL_UUID = "00000000-0000-0000-0000-000000000000";
-const AGENT_ID = "a1111111-1111-1111-1111-111111111111";
const USER_ID = "u1111111-1111-1111-1111-111111111111";
function msg(overrides: Partial): Message {
@@ -17,7 +12,6 @@ function msg(overrides: Partial): Message {
authorId: USER_ID,
authorType: "human",
content: "hello",
- mentions: [],
createdAt: "2026-06-08T12:00:00Z",
status: "sent",
...overrides,
@@ -25,54 +19,46 @@ function msg(overrides: Partial): Message {
}
describe("isVisibleInChat", () => {
- it("keeps human messages visible regardless of agentIds", () => {
- const m = msg({ authorType: "human", authorId: USER_ID });
- expect(isVisibleInChat(m, new Set())).toBe(true);
- expect(isVisibleInChat(m, new Set([AGENT_ID, USER_ID]))).toBe(true);
+ it("keeps human messages visible", () => {
+ expect(isVisibleInChat(msg({ authorType: "human" }))).toBe(true);
});
- it("hides an agent reply whose author is a session agent", () => {
- const m = msg({ authorType: "agent", authorId: AGENT_ID });
- expect(isVisibleInChat(m, new Set([AGENT_ID]))).toBe(false);
+ it("hides deuce's task replies", () => {
+ const m = msg({ authorType: "agent", authorId: DEUCE.id });
+ expect(isVisibleInChat(m)).toBe(false);
});
it("keeps system notices visible (agent-typed, nil author ID)", () => {
- const m = msg({ authorType: "agent", authorId: NIL_UUID });
- expect(isVisibleInChat(m, new Set([AGENT_ID]))).toBe(true);
+ const m = msg({ authorType: "agent", authorId: SYSTEM_AUTHOR_ID });
+ expect(isVisibleInChat(m)).toBe(true);
});
- it("keeps agent-typed messages visible when the author is not a session agent", () => {
- // Defensive: e.g. the agent was removed from the session.
+ it("hides agent-typed messages with an unexpected legacy author", () => {
+ // Post-migration these shouldn't exist (013 repoints history to DEUCE.id);
+ // hiding is the safe shape — an unknown agent author must not surface a
+ // duplicate reply in chat.
const m = msg({ authorType: "agent", authorId: "gone-agent" });
- expect(isVisibleInChat(m, new Set([AGENT_ID]))).toBe(true);
- });
-
- it("hides nothing when agentIds is empty", () => {
- const m = msg({ authorType: "agent", authorId: AGENT_ID });
- expect(isVisibleInChat(m, new Set())).toBe(true);
+ expect(isVisibleInChat(m)).toBe(false);
});
});
+
describe("visibleChatMessages", () => {
it("returns only visible messages in original order without mutating input", () => {
const human1 = msg({ id: "m1", authorType: "human", authorId: USER_ID });
- const reply = msg({ id: "m2", authorType: "agent", authorId: AGENT_ID });
- const notice = msg({ id: "m3", authorType: "agent", authorId: NIL_UUID });
+ const reply = msg({ id: "m2", authorType: "agent", authorId: DEUCE.id });
+ const notice = msg({
+ id: "m3",
+ authorType: "agent",
+ authorId: SYSTEM_AUTHOR_ID,
+ });
const human2 = msg({ id: "m4", authorType: "human", authorId: USER_ID });
const input = [human1, reply, notice, human2];
- const out = visibleChatMessages(input, new Set([AGENT_ID]));
+ const out = visibleChatMessages(input);
expect(out).toEqual([human1, notice, human2]);
expect(input).toHaveLength(4);
expect(input[1]).toBe(reply);
});
-
- it("returns everything when agentIds is empty", () => {
- const input = [
- msg({ id: "m1", authorType: "agent", authorId: AGENT_ID }),
- msg({ id: "m2", authorType: "human", authorId: USER_ID }),
- ];
- expect(visibleChatMessages(input, new Set())).toEqual(input);
- });
});
diff --git a/src/components/chat/message-visibility.ts b/src/components/chat/message-visibility.ts
index fb994a7..ef79774 100644
--- a/src/components/chat/message-visibility.ts
+++ b/src/components/chat/message-visibility.ts
@@ -1,25 +1,22 @@
import type { Message } from "@/types";
+import { SYSTEM_AUTHOR_ID } from "@/lib/deuce";
-// Agent task replies are surfaced on the super-thread surfaces (the inline
-// AgentTaskCard and the agent thread drawer), so their chat-bubble copy is
-// hidden from the main chat list. System/operational notices are posted with
-// authorType "agent" but a nil author ID that matches no session agent
-// (postSystemMessage in server/internal/handler/messages.go) — they fall
-// through the agentIds check and stay visible, as do all human messages.
-export function isVisibleInChat(
- message: Message,
- agentIds: Set,
-): boolean {
- return !(message.authorType === "agent" && agentIds.has(message.authorId));
+// Deuce's task replies are surfaced on the super-thread surfaces (the inline
+// AgentTaskCard and the thread drawer), so their chat-bubble copy is hidden
+// from the main chat list. System/operational notices are posted with
+// authorType "agent" but the nil author ID (postSystemMessage in
+// server/internal/handler/messages.go) — they stay visible, as do all human
+// messages. Agent-typed messages with any other author should not exist
+// post-migration (013 repoints history to DEUCE.id), but hide them too so an
+// unexpected author can't leak a duplicate reply into chat.
+export function isVisibleInChat(message: Message): boolean {
+ return !(
+ message.authorType === "agent" && message.authorId !== SYSTEM_AUTHOR_ID
+ );
}
// visibleChatMessages filters a message list for the main chat surface,
-// preserving order and never mutating the input. agentIds is the set of the
-// session's agent IDs (derived from session.agents by the caller — this
-// module stays free of store/session imports).
-export function visibleChatMessages(
- messages: Message[],
- agentIds: Set,
-): Message[] {
- return messages.filter((m) => isVisibleInChat(m, agentIds));
+// preserving order and never mutating the input.
+export function visibleChatMessages(messages: Message[]): Message[] {
+ return messages.filter(isVisibleInChat);
}
diff --git a/src/components/layout/SummaryPanel.tsx b/src/components/layout/SummaryPanel.tsx
index 497ba22..abc4bb3 100644
--- a/src/components/layout/SummaryPanel.tsx
+++ b/src/components/layout/SummaryPanel.tsx
@@ -4,35 +4,41 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { useSessionStore } from "@/stores/session-store";
+import { deuceStatus, type DeuceStatus } from "@/stores/agent-runs";
+import { DEUCE } from "@/lib/deuce";
import { ActivityFeed } from "@/components/activity/ActivityFeed";
import { ManageMembersDialog } from "@/components/session/ManageMembersDialog";
-import type { Agent, User } from "@/types";
+import type { User } from "@/types";
-function AgentRow({ agent }: { agent: Agent }) {
+// DeuceRow shows the one built-in agent. Status derives from task state (the
+// agentRuns reducer) — working while a task runs, waiting on a pending
+// question, idle otherwise.
+function DeuceRow({ status }: { status: DeuceStatus }) {
const statusStyles = {
idle: "bg-neutral-8",
working: "bg-success animate-pulse-dot",
- "warming-up": "bg-warning",
- error: "bg-danger",
- }[agent.status];
+ waiting: "bg-warning animate-pulse-dot",
+ }[status];
+ const label =
+ status === "working"
+ ? "working"
+ : status === "waiting"
+ ? "needs input"
+ : "idle";
return (
- {agent.name[0]}
-
-
-
-
- {agent.name}
-
-
-
- {agent.description}
+ {DEUCE.name[0]}
+
+ {DEUCE.name}
+
+
+ {label}
);
}
@@ -61,7 +67,7 @@ function UserRow({ user }: { user: User }) {
}
export function SummaryPanel() {
- const { activeSessionId, sessions, activities } = useSessionStore();
+ const { activeSessionId, sessions, activities, agentRuns } = useSessionStore();
const [membersOpen, setMembersOpen] = useState(false);
if (!activeSessionId) {
@@ -78,6 +84,7 @@ export function SummaryPanel() {
if (!session) return null;
const sessionActivities = activities[activeSessionId] ?? [];
+ const status = deuceStatus(agentRuns[activeSessionId]);
return (
+
+
);
}
diff --git a/src/components/super-threads/AgentTaskCard.tsx b/src/components/super-threads/AgentTaskCard.tsx
index d6d252b..ea2923d 100644
--- a/src/components/super-threads/AgentTaskCard.tsx
+++ b/src/components/super-threads/AgentTaskCard.tsx
@@ -1,9 +1,7 @@
// AgentTaskCard — the inline card rendered beneath the chat message that
-// spawned an agent task (anchorMessageId). One card per task; its appearance
-// switches on task.state. Clicking opens the agent's thread drawer.
-//
-// Ported from the prototype's TaskCard (queue-app.jsx), wired to real
-// AgentTask/AgentAction reducer state instead of the demo's timer simulation.
+// spawned a deuce task (anchorMessageId). One card per task; its appearance
+// switches on task.state. Clicking opens the session's thread drawer; the
+// Stop button on a live card cancels the run without opening the drawer.
import {
Loader,
@@ -13,17 +11,18 @@ import {
AlertCircle,
XCircle,
} from "lucide-react";
-import type { Agent, AgentTask } from "@/types";
-import { AgentAvatar, TypingDots } from "./atoms";
+import type { AgentTask } from "@/types";
+import { DEUCE } from "@/lib/deuce";
+import { AgentAvatar, StopButton, TypingDots } from "./atoms";
import { stripMention } from "./utils";
export function AgentTaskCard({
- agent,
+ sessionId,
task,
queuePos,
onOpen,
}: {
- agent: Agent;
+ sessionId: string;
task: AgentTask;
queuePos?: number;
onOpen: () => void;
@@ -32,19 +31,24 @@ export function AgentTaskCard({
const latest = task.actions[task.actions.length - 1];
return (
-
+
{state === "running" && (
<>
-
- {agent.name}
+
+ {DEUCE.name}· session thread
Working
+
@@ -59,19 +63,19 @@ export function AgentTaskCard({
-
- {agent.name} is working — open to watch
+
+ {DEUCE.name} is working — open to watch
>
)}
{state === "awaiting_input" && (
-
+
- {agent.name} needs your input — open to answer
+ {DEUCE.name} needs your input — open to answer
- Queued for {agent.name} · waiting for current task
+ Queued for {DEUCE.name} · waiting for current task
{stripMention(task.prompt)}
@@ -99,7 +103,7 @@ export function AgentTaskCard({
{(state === "done" || state === "failed" || state === "cancelled") && (
-
+
{state === "done" ? (
@@ -110,7 +114,7 @@ export function AgentTaskCard({
)}
- {agent.name}{" "}
+ {DEUCE.name}{" "}
{task.reply ??
(state === "failed"
? "Run failed."
diff --git a/src/components/super-threads/AgentThreadDrawer.tsx b/src/components/super-threads/AgentThreadDrawer.tsx
index d674105..434c490 100644
--- a/src/components/super-threads/AgentThreadDrawer.tsx
+++ b/src/components/super-threads/AgentThreadDrawer.tsx
@@ -1,7 +1,8 @@
-// AgentThreadDrawer — the per-agent global thread shown in the right panel.
-// Lists every task for one agent in chronological order (the agent's whole
-// history in this session), with a Claude Code-style action log per turn and a
-// composer that steers the agent (or enqueues a new task when it's idle).
+// AgentThreadDrawer — the session's deuce thread shown in the right panel.
+// Lists every task in chronological order (the session's whole agent history),
+// with a Claude Code-style action log per turn and a composer that steers the
+// agent (or enqueues a new task when it's idle). The header carries a Stop
+// button while a run is live.
//
// Ported from the prototype's Drawer + Turn (queue-app.jsx), wired to real
// reducer state and the store's steer() action.
@@ -15,8 +16,10 @@ import {
ChevronDown,
AlertCircle,
} from "lucide-react";
-import type { Agent, AgentTask, User } from "@/types";
-import { AgentAvatar, TypingDots, Mentioned, ActionLog } from "./atoms";
+import type { AgentTask, User } from "@/types";
+import { DEUCE } from "@/lib/deuce";
+import { statusOfTasks } from "@/stores/agent-runs";
+import { AgentAvatar, StopButton, TypingDots, Mentioned, ActionLog } from "./atoms";
type UserLookup = (id?: string) => Pick | undefined;
@@ -101,13 +104,11 @@ function QuestionControls({
}
function Turn({
- agent,
task,
queuePos,
lookupUser,
onAnswer,
}: {
- agent: Agent;
task: AgentTask;
queuePos?: number;
lookupUser: UserLookup;
@@ -128,28 +129,28 @@ function Turn({
{requester?.name ?? "Someone"}
-
+
{task.state === "running" && (
-
+
-
-
- {agent.name} is working…
+
+
+ {DEUCE.name} is working…
)}
{task.state === "awaiting_input" && (
-
+
- {agent.name} needs your input
+ {DEUCE.name} needs your input
{task.pendingQuestion ?? "Reply below to continue."}
@@ -160,10 +161,10 @@ function Turn({
)}
{terminal && (
-
- Start of thread with {agent.name}
+ Start of thread with {DEUCE.name}
{tasks.length === 0 ? (
- No tasks yet. Send {agent.name} a message below.
+ No tasks yet. Send {DEUCE.name} a message below.
) : (
tasks.map((t) => (
setVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
diff --git a/src/components/super-threads/ThreadDrawerPanel.tsx b/src/components/super-threads/ThreadDrawerPanel.tsx
index 145affe..9072fa2 100644
--- a/src/components/super-threads/ThreadDrawerPanel.tsx
+++ b/src/components/super-threads/ThreadDrawerPanel.tsx
@@ -1,9 +1,9 @@
-// ThreadDrawerPanel — store-connected wrapper that renders the AgentThreadDrawer
-// for whichever agent thread is currently open. AppShell mounts this in the
-// right panel in place of the SummaryPanel when openThread is set.
+// ThreadDrawerPanel — store-connected wrapper that renders the session's deuce
+// thread drawer when it is open. AppShell mounts this in the right panel in
+// place of the SummaryPanel when openThread is set.
import { useSessionStore } from "@/stores/session-store";
-import { tasksForAgent, queuePositions } from "@/stores/agent-runs";
+import { sessionTaskList, queuePositions } from "@/stores/agent-runs";
import type { User } from "@/types";
import { AgentThreadDrawer } from "./AgentThreadDrawer";
@@ -18,11 +18,10 @@ export function ThreadDrawerPanel() {
if (!openThread) return null;
const session = sessions.find((s) => s.id === openThread.sessionId);
- const agent = session?.agents.find((a) => a.id === openThread.agentId);
- if (!session || !agent) return null;
+ if (!session) return null;
const runs = agentRuns[openThread.sessionId];
- const tasks = tasksForAgent(runs, agent.id);
+ const tasks = sessionTaskList(runs);
const qpos = queuePositions(runs);
// requestedBy ids resolve against session members plus the current user.
@@ -33,12 +32,12 @@ export function ThreadDrawerPanel() {
return (
steer(openThread.sessionId, agent.id, message)}
+ onSend={(message) => steer(openThread.sessionId, message)}
/>
);
}
diff --git a/src/components/super-threads/atoms.tsx b/src/components/super-threads/atoms.tsx
index 86f2199..f91b6a1 100644
--- a/src/components/super-threads/atoms.tsx
+++ b/src/components/super-threads/atoms.tsx
@@ -14,9 +14,12 @@ import {
Loader2,
CircleDot,
AlertCircle,
+ Square,
type LucideIcon,
} from "lucide-react";
-import type { Agent, AgentAction } from "@/types";
+import type { AgentAction } from "@/types";
+import { DEUCE } from "@/lib/deuce";
+import { api } from "@/lib/api";
// Tool → icon, mirroring TOOL_ICON in the prototype. Unknown tools fall back to
// a neutral dot.
@@ -29,13 +32,14 @@ const TOOL_ICON: Record = {
Think: Sparkles,
};
-// AgentAvatar is the colored initial square used wherever an agent appears.
-export function AgentAvatar({ agent, size = 22 }: { agent: Agent; size?: number }) {
+// AgentAvatar is deuce's colored initial square, used wherever the agent
+// appears. There is one agent, so identity comes from the DEUCE constant.
+export function AgentAvatar({ size = 22 }: { size?: number }) {
return (
- {agent.name[0]}
+ {DEUCE.name[0]}
);
}
// TypingDots renders the three pulsing dots in the agent's color.
-export function TypingDots({ color }: { color: string }) {
+export function TypingDots() {
return (
{[0, 0.2, 0.4].map((d, i) => (
))}
@@ -69,7 +73,7 @@ export function TypingDots({ color }: { color: string }) {
}
// Mentioned highlights @mentions inline in the agent's color.
-export function Mentioned({ text, color }: { text: string; color?: string }) {
+export function Mentioned({ text }: { text: string }) {
return (
<>
{text.split(/(@\w+)/g).map((part, i) =>
@@ -77,7 +81,7 @@ export function Mentioned({ text, color }: { text: string; color?: string }) {
{part}
@@ -89,6 +93,29 @@ export function Mentioned({ text, color }: { text: string; color?: string }) {
);
}
+// StopButton cancels the session's running task and drains its queue — the
+// same semantics as the /stop chat command. Shared by the inline task card
+// and the thread-drawer header. stopPropagation keeps it from triggering the
+// click-to-open behavior of whatever container it sits in.
+export function StopButton({ sessionId }: { sessionId: string }) {
+ return (
+
+ );
+}
+
function statusClass(action: AgentAction): "run" | "done" | "error" {
if (action.status === "started") return "run";
if (action.status === "error" || action.isError) return "error";
diff --git a/src/hooks/use-websocket.ts b/src/hooks/use-websocket.ts
index 8bdd6a9..f9e58c6 100644
--- a/src/hooks/use-websocket.ts
+++ b/src/hooks/use-websocket.ts
@@ -1,7 +1,6 @@
import { useEffect, useRef, useCallback } from "react";
import { useSessionStore } from "@/stores/session-store";
import type {
- AgentStatus,
Message,
ActivityItem,
TaskEventPayload,
@@ -61,13 +60,8 @@ export function useWebSocket() {
const {
activeSessionId,
addMessage,
- setThinkingAgent,
- clearThinkingAgent,
- updateAgentStatus,
addActivity,
appendWorkspaceLog,
- appendAgentOutput,
- clearAgentOutput,
} = useSessionStore();
// Refs hold the latest version of handleMessage and connect so the
@@ -91,7 +85,6 @@ export function useWebSocket() {
authorType: message.authorType,
content: message.content,
expandableContent: message.expandableContent,
- mentions: message.mentions ?? [],
createdAt: message.createdAt,
status: message.status ?? "sent",
};
@@ -99,32 +92,6 @@ export function useWebSocket() {
break;
}
- case "agent_status": {
- const { agentId, status } = msg.payload as {
- agentId: string;
- status: AgentStatus;
- };
- updateAgentStatus(msg.sessionId, agentId, status);
- // Clear streaming output when agent finishes
- if (status === "idle" || status === "error") {
- clearAgentOutput(msg.sessionId);
- }
- break;
- }
-
- case "typing_indicator": {
- const { agentId, active } = msg.payload as {
- agentId: string;
- active: boolean;
- };
- if (active) {
- setThinkingAgent(msg.sessionId, agentId);
- } else {
- clearThinkingAgent(msg.sessionId, agentId);
- }
- break;
- }
-
case "activity_update": {
const activity = msg.payload as ActivityItem;
const sessionId = msg.sessionId || activity.sessionId;
@@ -139,16 +106,6 @@ export function useWebSocket() {
break;
}
- case "agent_output": {
- const { agentId, content, contentType } = msg.payload as {
- agentId: string;
- content: string;
- contentType: string;
- };
- appendAgentOutput(msg.sessionId, { agentId, content, contentType });
- break;
- }
-
case "workspace_log": {
const { line } = msg.payload as { line: string };
appendWorkspaceLog(msg.sessionId, line);
@@ -191,7 +148,7 @@ export function useWebSocket() {
}
}
},
- [addMessage, updateAgentStatus, setThinkingAgent, clearThinkingAgent, addActivity, appendWorkspaceLog, appendAgentOutput, clearAgentOutput],
+ [addMessage, addActivity, appendWorkspaceLog],
);
// Keep the latest handleMessage reachable from the long-lived ws.onmessage
@@ -311,14 +268,11 @@ export function useWebSocket() {
// sendSteer delivers a drawer reply to a live agent run (feed/answer) or, if
// the agent is idle, enqueues a new task server-side (R15/R19). The server
// also posts the reply to the channel for shared visibility.
- const sendSteer = useCallback(
- (sessionId: string, agentId: string, message: string) => {
- const ws = wsRef.current;
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
- ws.send(JSON.stringify({ type: "steer", sessionId, agentId, message }));
- },
- [],
- );
+ const sendSteer = useCallback((sessionId: string, message: string) => {
+ const ws = wsRef.current;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ ws.send(JSON.stringify({ type: "steer", sessionId, message }));
+ }, []);
// Register sendSteer into the store so the thread-drawer composer can steer
// agents without re-instantiating this hook (it's mounted once in App). The
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 3dbfcc6..8d75bb1 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1,7 +1,7 @@
import type {
ActivityItem,
- Agent,
AgentRunSnapshot,
+ AgentSettings,
FileContentResponse,
FileNode,
Message,
@@ -34,21 +34,11 @@ interface MessagesPage {
hasMore: boolean;
}
-interface AgentMutation {
- name: string;
- role: string;
- provider: string;
- model: string;
- description: string;
- systemPrompt: string;
-}
-
interface CreateSessionBody {
name: string;
description?: string;
projectId: string;
repoUrl?: string;
- agentIds: string[];
memberIds: string[];
}
@@ -61,7 +51,6 @@ interface UpdateSessionBody {
interface SendMessageBody {
content: string;
- mentions: string[];
}
const BASE = "/api";
@@ -140,25 +129,21 @@ export const api = {
listProjects: (teamId?: string) =>
request(teamId ? `/projects?teamId=${teamId}` : "/projects"),
- listAgents: () => request("/agents"),
+ // Single built-in agent settings: deuce's global system prompt. A save
+ // takes effect on each session's next Pi process launch (idle processes are
+ // recycled server-side; sessions mid-task pick it up later).
+ getAgentSettings: () => request("/agent"),
- createAgent: (body: AgentMutation) =>
- request("/agents", {
- method: "POST",
- body: JSON.stringify(body),
- }),
-
- updateAgent: (id: string, body: AgentMutation) =>
- request(`/agents/${id}`, {
+ updateAgentSettings: (systemPrompt: string) =>
+ request("/agent", {
method: "PUT",
- body: JSON.stringify(body),
+ body: JSON.stringify({ systemPrompt }),
}),
- deleteAgent: (id: string) =>
- request(`/agents/${id}`, { method: "DELETE" }),
-
+ // Cancels the session's running task and drains its queue (same semantics
+ // as the /stop chat command).
stopAgent: (sessionId: string) =>
- request(`/sessions/${sessionId}/agents/stop`, { method: "POST" }),
+ request(`/sessions/${sessionId}/agent/stop`, { method: "POST" }),
listSessions: () => request("/sessions"),
@@ -199,12 +184,6 @@ export const api = {
getAgentRuns: (sessionId: string) =>
request(`/sessions/${sessionId}/agent-runs`),
- updateSessionAgents: (sessionId: string, agentIds: string[]) =>
- request(`/sessions/${sessionId}/agents`, {
- method: "PUT",
- body: JSON.stringify({ agentIds }),
- }),
-
addSessionMember: (
sessionId: string,
body: { userId?: string; email?: string },
diff --git a/src/lib/deuce.ts b/src/lib/deuce.ts
new file mode 100644
index 0000000..e556791
--- /dev/null
+++ b/src/lib/deuce.ts
@@ -0,0 +1,15 @@
+// The single built-in agent. The id MUST match agent.DeuceAgentID in
+// server/internal/agent/store.go and the row seeded by migration
+// 013_single_deuce_agent.sql — message authorship, the chat visibility
+// filter, and historical repoints all pin to it.
+export const DEUCE = {
+ id: "00000000-0000-0000-0000-00000000000d",
+ name: "deuce",
+ // Accent + muted variants (former Coder blue — the channel's one agent color).
+ color: "#58a6ff",
+ colorMuted: "#0c2d6b",
+} as const;
+
+// The nil UUID is the system-notice author sentinel (authorType "agent" with
+// this id renders as a system notice and stays visible in chat).
+export const SYSTEM_AUTHOR_ID = "00000000-0000-0000-0000-000000000000";
diff --git a/src/mocks/data/seed.ts b/src/mocks/data/seed.ts
deleted file mode 100644
index a3564db..0000000
--- a/src/mocks/data/seed.ts
+++ /dev/null
@@ -1,858 +0,0 @@
-import type {
- Team,
- Project,
- Session,
- Message,
- Agent,
- User,
- FileNode,
- ActivityItem,
-} from "@/types";
-
-// ── Users ────────────────────────────────────────────────────────
-
-export const users: User[] = [
- {
- id: "current-user",
- name: "Clint Berry",
- email: "clint@forge.dev",
- avatar: "https://api.dicebear.com/9.x/avataaars/svg?seed=Clint",
- status: "online",
- },
- {
- id: "user-2",
- name: "Sarah Chen",
- email: "sarah@forge.dev",
- avatar: "https://api.dicebear.com/9.x/avataaars/svg?seed=Sarah",
- status: "online",
- },
- {
- id: "user-3",
- name: "Mike Rodriguez",
- email: "mike@forge.dev",
- avatar: "https://api.dicebear.com/9.x/avataaars/svg?seed=Mike",
- status: "offline",
- },
- {
- id: "user-4",
- name: "Alex Park",
- email: "alex@acme.co",
- avatar: "https://api.dicebear.com/9.x/avataaars/svg?seed=Alex",
- status: "online",
- },
- {
- id: "user-5",
- name: "Jordan Lee",
- email: "jordan@acme.co",
- avatar: "https://api.dicebear.com/9.x/avataaars/svg?seed=Jordan",
- status: "offline",
- },
-];
-
-// ── Agents ───────────────────────────────────────────────────────
-
-export const agentPresets: Agent[] = [
- {
- id: "agent-coder",
- name: "Coder",
- role: "coder",
- color: "#58a6ff",
- colorMuted: "#0c2d6b",
- status: "idle",
- provider: "Anthropic",
- model: "Claude Sonnet 4",
- description: "Writes and modifies code",
- systemPrompt: "You are a coder agent. Write and modify code to match the spec the team agrees on. Match existing patterns; ask before introducing new abstractions.",
- },
- {
- id: "agent-reviewer",
- name: "Reviewer",
- role: "reviewer",
- color: "#BE8FFF",
- colorMuted: "#3c1e70",
- status: "idle",
- provider: "Anthropic",
- model: "Claude Sonnet 4",
- description: "Reviews code changes",
- systemPrompt: "You are a reviewer agent. Read diffs critically: correctness, edge cases, security, test coverage. Be direct; cite file:line.",
- },
- {
- id: "agent-planner",
- name: "Planner",
- role: "planner",
- color: "#3fb950",
- colorMuted: "#033a16",
- status: "idle",
- provider: "OpenAI",
- model: "GPT-4o",
- description: "Creates implementation plans",
- systemPrompt: "You are a planner agent. Break work into ordered implementation units with file paths and test scenarios. Decisions over tasks; flag deferred items explicitly.",
- },
- {
- id: "agent-tester",
- name: "Tester",
- role: "tester",
- color: "#d29922",
- colorMuted: "#4b2900",
- status: "idle",
- provider: "Anthropic",
- model: "Claude Sonnet 4",
- description: "Writes and runs tests",
- systemPrompt: "You are a tester agent. Write tests that exercise real interactions (callbacks, middleware, integration paths). Prefer integration over mocks for layer crossings.",
- },
- {
- id: "agent-designer",
- name: "Designer",
- role: "designer",
- color: "#f778ba",
- colorMuted: "#5e103e",
- status: "idle",
- provider: "OpenAI",
- model: "GPT-4o",
- description: "UI/UX suggestions",
- systemPrompt: "You are a designer agent. Suggest interface improvements grounded in the existing design system. Show concrete before/after snippets.",
- },
-];
-
-function getAgent(id: string): Agent {
- return { ...agentPresets.find((a) => a.id === id)! };
-}
-
-// ── Teams ────────────────────────────────────────────────────────
-
-export const teams: Team[] = [
- {
- id: "team-1",
- name: "Forge Utah",
- slug: "forge-utah",
- members: [users[0], users[1], users[2]],
- },
- {
- id: "team-2",
- name: "Acme Corp",
- slug: "acme-corp",
- members: [users[3], users[4]],
- },
-];
-
-// ── Projects ─────────────────────────────────────────────────────
-
-export const projects: Project[] = [
- {
- id: "proj-1",
- name: "forge-api",
- repoUrl: "https://github.com/forgeutah/forge-api",
- teamId: "team-1",
- },
- {
- id: "proj-2",
- name: "forge-web",
- repoUrl: "https://github.com/forgeutah/forge-web",
- teamId: "team-1",
- },
- {
- id: "proj-3",
- name: "acme-dashboard",
- repoUrl: "https://github.com/acmecorp/dashboard",
- teamId: "team-2",
- },
-];
-
-// ── Sessions ─────────────────────────────────────────────────────
-
-const now = new Date();
-const minutesAgo = (m: number) => new Date(now.getTime() - m * 60000).toISOString();
-const hoursAgo = (h: number) => new Date(now.getTime() - h * 3600000).toISOString();
-const daysAgo = (d: number) => new Date(now.getTime() - d * 86400000).toISOString();
-
-export const sessions: Session[] = [
- {
- id: "sess-1",
- name: "auth-module",
- description: "JWT validation and refresh-token flow for the v2 API",
- projectId: "proj-1",
- status: "active",
- agents: [getAgent("agent-coder"), getAgent("agent-reviewer"), getAgent("agent-tester")],
- members: [users[0], users[1]],
- unreadCount: 3,
- createdAt: daysAgo(2),
- lastActivityAt: minutesAgo(5),
- workspaceStatus: "ready",
- planContent: `# Auth Module Plan
-
-## Goals
-- [ ] Implement JWT token validation
-- [ ] Add token expiration checks
-- [x] Set up auth middleware
-- [x] Create user model
-
-## Technical Notes
-- Using \`golang-jwt/jwt/v5\` for JWT parsing
-- Token expiry window: 24 hours
-- Refresh tokens stored in Redis
-
-## Acceptance Criteria
-- All endpoints behind auth middleware return 401 without valid token
-- Expired tokens are rejected with appropriate error message
-- Token refresh flow works end-to-end
-`,
- },
- {
- id: "sess-2",
- name: "api-rate-limiting",
- description: "Token-bucket rate limiter via Redis, per-endpoint config",
- projectId: "proj-1",
- status: "active",
- agents: [getAgent("agent-coder"), getAgent("agent-planner")],
- members: [users[0], users[2]],
- unreadCount: 0,
- createdAt: daysAgo(1),
- lastActivityAt: hoursAgo(2),
- workspaceStatus: "ready",
- planContent: `# Rate Limiting Plan
-
-## Approach
-- Token bucket algorithm
-- Per-user rate limits via Redis
-- Configurable limits per endpoint
-
-## TODO
-- [ ] Implement rate limiter middleware
-- [ ] Add Redis integration
-- [ ] Configure per-route limits
-`,
- },
- {
- id: "sess-3",
- name: "homepage-redesign",
- description: "Marketing homepage refresh with the new hero animation",
- projectId: "proj-2",
- status: "active",
- agents: [getAgent("agent-coder"), getAgent("agent-designer")],
- members: [users[0], users[1]],
- unreadCount: 1,
- createdAt: daysAgo(3),
- lastActivityAt: minutesAgo(30),
- workspaceStatus: "ready",
- planContent: "",
- },
- {
- id: "sess-4",
- name: "ci-pipeline",
- description: "",
- projectId: "proj-1",
- status: "paused",
- agents: [getAgent("agent-coder")],
- members: [users[2]],
- unreadCount: 0,
- createdAt: daysAgo(5),
- lastActivityAt: daysAgo(2),
- workspaceStatus: "stopped",
- planContent: "",
- },
- {
- id: "sess-5",
- name: "onboarding-flow",
- description: "New-user onboarding wizard — paused while we finalize copy",
- projectId: "proj-2",
- status: "archived",
- agents: [getAgent("agent-coder"), getAgent("agent-planner")],
- members: [users[0], users[1]],
- unreadCount: 0,
- createdAt: daysAgo(14),
- lastActivityAt: daysAgo(7),
- workspaceStatus: "stopped",
- planContent: "",
- },
- {
- id: "sess-6",
- name: "dashboard-charts",
- description: "Recharts integration for the customer analytics dashboard",
- projectId: "proj-3",
- status: "active",
- agents: [getAgent("agent-coder"), getAgent("agent-reviewer"), getAgent("agent-tester")],
- members: [users[3], users[4]],
- unreadCount: 2,
- createdAt: daysAgo(1),
- lastActivityAt: minutesAgo(15),
- workspaceStatus: "starting",
- planContent: "",
- },
-];
-
-// ── Messages ─────────────────────────────────────────────────────
-
-export const messagesBySession: Record = {
- "sess-1": [
- {
- id: "msg-1",
- sessionId: "sess-1",
- authorId: "current-user",
- authorType: "human",
- content: "Let's start working on the auth module. We need JWT validation with token expiration checking.",
- mentions: [],
- createdAt: hoursAgo(4),
- status: "sent",
- },
- {
- id: "msg-2",
- sessionId: "sess-1",
- authorId: "user-2",
- authorType: "human",
- content: "Agreed. I've updated the plan with acceptance criteria. @Planner can you break this down?",
- mentions: ["agent-planner"],
- createdAt: hoursAgo(3.5),
- status: "sent",
- },
- {
- id: "msg-3",
- sessionId: "sess-1",
- authorId: "agent-coder",
- authorType: "agent",
- content: "I've set up the auth middleware and user model. The base structure is ready for JWT integration.",
- expandableContent: [
- {
- type: "diff",
- title: "changes",
- summary: "internal/auth/middleware.go (+45 -0)",
- content: `+package auth
-+
-+import (
-+ "net/http"
-+ "strings"
-+)
-+
-+func Middleware(next http.Handler) http.Handler {
-+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-+ token := extractToken(r)
-+ if token == "" {
-+ http.Error(w, "unauthorized", http.StatusUnauthorized)
-+ return
-+ }
-+
-+ if err := Validate(token); err != nil {
-+ http.Error(w, err.Error(), http.StatusUnauthorized)
-+ return
-+ }
-+
-+ next.ServeHTTP(w, r)
-+ })
-+}
-+
-+func extractToken(r *http.Request) string {
-+ auth := r.Header.Get("Authorization")
-+ if strings.HasPrefix(auth, "Bearer ") {
-+ return strings.TrimPrefix(auth, "Bearer ")
-+ }
-+ return ""
-+}`,
- },
- ],
- mentions: [],
- createdAt: hoursAgo(3),
- status: "sent",
- },
- {
- id: "msg-4",
- sessionId: "sess-1",
- authorId: "current-user",
- authorType: "human",
- content: "@Coder now add token expiration checking to the Validate function",
- mentions: ["agent-coder"],
- createdAt: hoursAgo(2),
- status: "sent",
- },
- {
- id: "msg-5",
- sessionId: "sess-1",
- authorId: "agent-coder",
- authorType: "agent",
- content: "I've updated the Validate function with token expiration checking. The implementation parses JWT claims and checks the expiry timestamp.",
- expandableContent: [
- {
- type: "diff",
- title: "changes",
- summary: "internal/auth/validate.go (+12 -3)",
- content: `@@ -42,8 +42,19 @@ func Validate(token string) error {
- return ErrInvalidFormat
- }
-
-+ // Check token expiration
-+ claims, err := ParseClaims(token)
-+ if err != nil {
-+ return fmt.Errorf("parse claims: %w", err)
-+ }
-+
-+ if claims.ExpiresAt.Before(time.Now()) {
-+ return ErrTokenExpired
-+ }
-+
- return nil
- }`,
- },
- ],
- mentions: [],
- createdAt: hoursAgo(1.5),
- status: "sent",
- },
- {
- id: "msg-6",
- sessionId: "sess-1",
- authorId: "current-user",
- authorType: "human",
- content: "@Tester can you write tests for the new expiration logic?",
- mentions: ["agent-tester"],
- createdAt: hoursAgo(1),
- status: "sent",
- },
- {
- id: "msg-7",
- sessionId: "sess-1",
- authorId: "agent-tester",
- authorType: "agent",
- content: "Tests are written and passing. I've covered valid tokens, expired tokens, invalid format, and empty input.",
- expandableContent: [
- {
- type: "test-results",
- title: "test results",
- summary: "4/4 passing",
- content: `=== RUN TestValidate
---- PASS: TestValidate (0.00s)
-=== RUN TestValidateExpired
---- PASS: TestValidateExpired (0.00s)
-=== RUN TestValidateInvalid
---- PASS: TestValidateInvalid (0.00s)
-=== RUN TestValidateEmpty
---- PASS: TestValidateEmpty (0.00s)
-PASS
-ok forge-api/internal/auth 0.003s`,
- },
- ],
- mentions: [],
- createdAt: minutesAgo(45),
- status: "sent",
- },
- {
- id: "msg-8",
- sessionId: "sess-1",
- authorId: "user-2",
- authorType: "human",
- content: "@Reviewer can you review all the auth changes?",
- mentions: ["agent-reviewer"],
- createdAt: minutesAgo(20),
- status: "sent",
- },
- {
- id: "msg-9",
- sessionId: "sess-1",
- authorId: "agent-reviewer",
- authorType: "agent",
- content: "Code looks good overall. Two suggestions:\n\n1. Consider using `errors.Is()` for error comparison in middleware\n2. Add context to the JWT parsing errors for better debugging\n\nThe token expiration logic is correctly implemented. Tests cover the main cases well.",
- mentions: [],
- createdAt: minutesAgo(10),
- status: "sent",
- },
- ],
- "sess-2": [
- {
- id: "msg-20",
- sessionId: "sess-2",
- authorId: "current-user",
- authorType: "human",
- content: "We need rate limiting on the API endpoints. @Planner can you outline an approach?",
- mentions: ["agent-planner"],
- createdAt: hoursAgo(6),
- status: "sent",
- },
- {
- id: "msg-21",
- sessionId: "sess-2",
- authorId: "agent-planner",
- authorType: "agent",
- content: "Here's my recommended approach for rate limiting:\n\n**Algorithm:** Token bucket (allows bursts while maintaining average rate)\n\n**Storage:** Redis for distributed rate limit state\n\n**Configuration:**\n- Default: 100 requests/minute per user\n- Auth endpoints: 10 requests/minute (stricter)\n- Public endpoints: 30 requests/minute per IP\n\n**Implementation phases:**\n1. Core rate limiter with in-memory store (for dev)\n2. Redis adapter\n3. Per-route configuration\n4. Rate limit headers in responses",
- mentions: [],
- createdAt: hoursAgo(5.5),
- status: "sent",
- },
- {
- id: "msg-22",
- sessionId: "sess-2",
- authorId: "user-3",
- authorType: "human",
- content: "Looks good. Make sure we add X-RateLimit headers in the response.",
- mentions: [],
- createdAt: hoursAgo(5),
- status: "sent",
- },
- ],
- "sess-3": [
- {
- id: "msg-30",
- sessionId: "sess-3",
- authorId: "current-user",
- authorType: "human",
- content: "We need to redesign the homepage. @Designer any ideas for improving the layout?",
- mentions: ["agent-designer"],
- createdAt: hoursAgo(8),
- status: "sent",
- },
- {
- id: "msg-31",
- sessionId: "sess-3",
- authorId: "agent-designer",
- authorType: "agent",
- content: "Here are my recommendations for the homepage redesign:\n\n1. **Hero section:** Move the CTA above the fold with a clear value proposition\n2. **Social proof:** Add a customer logos bar below the hero\n3. **Features grid:** Replace the feature list with a 3-column card layout\n4. **Dark/light contrast:** Use alternating section backgrounds for visual rhythm\n\nThe current layout has too much text density. Let's prioritize visual hierarchy.",
- mentions: [],
- createdAt: hoursAgo(7),
- status: "sent",
- },
- ],
- "sess-6": [
- {
- id: "msg-60",
- sessionId: "sess-6",
- authorId: "user-4",
- authorType: "human",
- content: "Starting work on the dashboard charts. We need bar charts, line charts, and a pie chart for the overview.",
- mentions: [],
- createdAt: hoursAgo(3),
- status: "sent",
- },
- {
- id: "msg-61",
- sessionId: "sess-6",
- authorId: "user-5",
- authorType: "human",
- content: "@Coder can you set up Recharts and create a basic bar chart component?",
- mentions: ["agent-coder"],
- createdAt: hoursAgo(2.5),
- status: "sent",
- },
- ],
-};
-
-// ── Activities ───────────────────────────────────────────────────
-
-export const activitiesBySession: Record = {
- "sess-1": [
- {
- id: "act-1",
- sessionId: "sess-1",
- type: "agent-action",
- description: "Reviewer completed code review",
- timestamp: minutesAgo(10),
- agentId: "agent-reviewer",
- },
- {
- id: "act-2",
- sessionId: "sess-1",
- type: "test-run",
- description: "4/4 tests passing",
- timestamp: minutesAgo(45),
- agentId: "agent-tester",
- },
- {
- id: "act-3",
- sessionId: "sess-1",
- type: "file-change",
- description: "validate.go",
- timestamp: hoursAgo(1.5),
- agentId: "agent-coder",
- metadata: { additions: "12", deletions: "3" },
- },
- {
- id: "act-4",
- sessionId: "sess-1",
- type: "file-change",
- description: "middleware.go",
- timestamp: hoursAgo(3),
- agentId: "agent-coder",
- metadata: { additions: "45", deletions: "0" },
- },
- {
- id: "act-5",
- sessionId: "sess-1",
- type: "commit",
- description: "a1b2c3d Add token expiration check",
- timestamp: hoursAgo(1),
- },
- ],
- "sess-2": [
- {
- id: "act-20",
- sessionId: "sess-2",
- type: "agent-action",
- description: "Planner created implementation plan",
- timestamp: hoursAgo(5.5),
- agentId: "agent-planner",
- },
- ],
-};
-
-// ── File Trees ───────────────────────────────────────────────────
-
-export const fileTreesBySession: Record = {
- "sess-1": [
- {
- id: "f-1",
- name: "cmd",
- path: "cmd",
- type: "directory",
- children: [
- {
- id: "f-2",
- name: "server",
- path: "cmd/server",
- type: "directory",
- children: [
- {
- id: "f-3",
- name: "main.go",
- path: "cmd/server/main.go",
- type: "file",
- language: "go",
- content: `package main
-
-import (
- "log"
- "net/http"
-
- "forge-api/internal/api"
- "forge-api/internal/auth"
-)
-
-func main() {
- mux := api.NewRouter()
- handler := auth.Middleware(mux)
-
- log.Println("Server starting on :8080")
- log.Fatal(http.ListenAndServe(":8080", handler))
-}`,
- },
- ],
- },
- ],
- },
- {
- id: "f-10",
- name: "internal",
- path: "internal",
- type: "directory",
- children: [
- {
- id: "f-11",
- name: "auth",
- path: "internal/auth",
- type: "directory",
- children: [
- {
- id: "f-12",
- name: "middleware.go",
- path: "internal/auth/middleware.go",
- type: "file",
- language: "go",
- modifiedBy: "agent-coder",
- content: `package auth
-
-import (
- "net/http"
- "strings"
-)
-
-func Middleware(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- token := extractToken(r)
- if token == "" {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- if err := Validate(token); err != nil {
- http.Error(w, err.Error(), http.StatusUnauthorized)
- return
- }
-
- next.ServeHTTP(w, r)
- })
-}
-
-func extractToken(r *http.Request) string {
- auth := r.Header.Get("Authorization")
- if strings.HasPrefix(auth, "Bearer ") {
- return strings.TrimPrefix(auth, "Bearer ")
- }
- return ""
-}`,
- },
- {
- id: "f-13",
- name: "validate.go",
- path: "internal/auth/validate.go",
- type: "file",
- language: "go",
- modifiedBy: "agent-coder",
- content: `package auth
-
-import (
- "errors"
- "fmt"
- "time"
-)
-
-var (
- ErrInvalidFormat = errors.New("invalid token format")
- ErrTokenExpired = errors.New("token expired")
-)
-
-func Validate(token string) error {
- if token == "" {
- return ErrInvalidFormat
- }
-
- claims, err := ParseClaims(token)
- if err != nil {
- return fmt.Errorf("parse claims: %w", err)
- }
-
- if claims.ExpiresAt.Before(time.Now()) {
- return ErrTokenExpired
- }
-
- return nil
-}`,
- },
- {
- id: "f-14",
- name: "validate_test.go",
- path: "internal/auth/validate_test.go",
- type: "file",
- language: "go",
- modifiedBy: "agent-tester",
- content: `package auth_test
-
-import (
- "testing"
- "forge-api/internal/auth"
-)
-
-func TestValidate(t *testing.T) {
- token := createValidToken(t)
- if err := auth.Validate(token); err != nil {
- t.Fatalf("expected nil, got %v", err)
- }
-}
-
-func TestValidateExpired(t *testing.T) {
- token := createExpiredToken(t)
- err := auth.Validate(token)
- if err == nil {
- t.Fatal("expected error for expired token")
- }
-}
-
-func TestValidateInvalid(t *testing.T) {
- err := auth.Validate("not-a-real-token")
- if err == nil {
- t.Fatal("expected error for invalid token")
- }
-}
-
-func TestValidateEmpty(t *testing.T) {
- err := auth.Validate("")
- if err == nil {
- t.Fatal("expected error for empty token")
- }
-}`,
- },
- ],
- },
- {
- id: "f-20",
- name: "api",
- path: "internal/api",
- type: "directory",
- children: [
- {
- id: "f-21",
- name: "router.go",
- path: "internal/api/router.go",
- type: "file",
- language: "go",
- content: `package api
-
-import "net/http"
-
-func NewRouter() *http.ServeMux {
- mux := http.NewServeMux()
- mux.HandleFunc("GET /health", healthHandler)
- mux.HandleFunc("GET /api/users", usersHandler)
- return mux
-}`,
- },
- ],
- },
- ],
- },
- {
- id: "f-30",
- name: "go.mod",
- path: "go.mod",
- type: "file",
- language: "go",
- content: `module forge-api
-
-go 1.22
-
-require (
- github.com/golang-jwt/jwt/v5 v5.2.1
-)`,
- },
- {
- id: "f-31",
- name: "README.md",
- path: "README.md",
- type: "file",
- language: "markdown",
- content: `# forge-api
-
-Backend API for Forge platform.
-
-## Getting Started
-
-\`\`\`bash
-go run cmd/server/main.go
-\`\`\``,
- },
- ],
-};
-
-// ── Initialize Store ─────────────────────────────────────────────
-
-export function seedStore(store: {
- setTeams: (t: Team[]) => void;
- setProjects: (p: Project[]) => void;
- setSessions: (s: Session[]) => void;
- setMessages: (id: string, m: Message[]) => void;
- setActivities: (id: string, a: ActivityItem[]) => void;
- setFileTrees: (id: string, f: FileNode[]) => void;
- setActiveSession: (id: string) => void;
-}) {
- store.setTeams(teams);
- store.setProjects(projects);
- store.setSessions(sessions);
-
- for (const [sessionId, msgs] of Object.entries(messagesBySession)) {
- store.setMessages(sessionId, msgs);
- }
-
- for (const [sessionId, acts] of Object.entries(activitiesBySession)) {
- store.setActivities(sessionId, acts);
- }
-
- for (const [sessionId, files] of Object.entries(fileTreesBySession)) {
- store.setFileTrees(sessionId, files);
- }
-
- // Default to first active session
- store.setActiveSession("sess-1");
-}
diff --git a/src/stores/agent-runs.test.ts b/src/stores/agent-runs.test.ts
index 4e97f83..3ada6c5 100644
--- a/src/stores/agent-runs.test.ts
+++ b/src/stores/agent-runs.test.ts
@@ -1,19 +1,19 @@
import { describe, expect, it } from "vitest";
-import { applyEvent, applySnapshot } from "./agent-runs";
+import {
+ applyEvent,
+ applySnapshot,
+ deuceStatus,
+ queuePositions,
+} from "./agent-runs";
import type { AgentRunSnapshot, TaskEventPayload } from "@/types";
-// NOTE: The frontend has no test runner wired up yet (see CLAUDE.md / repo.test.ts).
-// These Vitest-style specs capture the intended behavior and run as soon as a
-// runner is added. Until then, `npx tsc --noEmit` keeps them type-checked.
-
const empty = { tasks: {}, lastSeq: 0, nextOrder: 0 };
function awaitingEvent(extra: Partial): TaskEventPayload {
return {
seq: 1,
taskId: "t1",
- agentId: "a1",
pendingQuestion: "Which framework?",
...extra,
};
@@ -55,7 +55,6 @@ describe("agent-runs reducer — typed questions", () => {
const { state } = applyEvent(afterAsk, "task_started", {
seq: 2,
taskId: "t1",
- agentId: "a1",
});
const task = state.tasks["t1"];
expect(task.state).toBe("running");
@@ -70,8 +69,7 @@ describe("agent-runs reducer — typed questions", () => {
{
id: "t1",
sessionId: "s1",
- agentId: "a1",
- prompt: "@coder build it",
+ prompt: "@deuce build it",
state: "awaiting_input",
seq: 5,
pendingQuestion: "Which framework?",
@@ -88,3 +86,42 @@ describe("agent-runs reducer — typed questions", () => {
expect(task.pendingQuestionOptions).toEqual(["React", "Vue"]);
});
});
+
+describe("deuceStatus / queuePositions — single-session derivations", () => {
+ const seed = (states: ("queued" | "running" | "awaiting_input" | "done")[]) => {
+ let state = { tasks: {}, lastSeq: 0, nextOrder: 0 } as ReturnType<
+ typeof applySnapshot
+ >;
+ states.forEach((s, i) => {
+ state = applyEvent(state, "task_enqueued", {
+ seq: i * 2 + 1,
+ taskId: `t${i}`,
+ prompt: `p${i}`,
+ }).state;
+ if (s === "running" || s === "awaiting_input" || s === "done") {
+ state = applyEvent(state, "task_started", {
+ seq: i * 2 + 2,
+ taskId: `t${i}`,
+ }).state;
+ }
+ });
+ return state;
+ };
+
+ it("derives idle / working / waiting from task state", () => {
+ expect(deuceStatus(undefined)).toBe("idle");
+ expect(deuceStatus(seed(["queued"]))).toBe("idle");
+ expect(deuceStatus(seed(["running"]))).toBe("working");
+ const waiting = applyEvent(seed(["running"]), "task_awaiting_input", {
+ seq: 99,
+ taskId: "t0",
+ pendingQuestion: "?",
+ }).state;
+ expect(deuceStatus(waiting)).toBe("waiting");
+ });
+
+ it("numbers the session's queued tasks in creation order", () => {
+ const state = seed(["running", "queued", "queued"]);
+ expect(queuePositions(state)).toEqual({ t1: 1, t2: 2 });
+ });
+});
diff --git a/src/stores/agent-runs.ts b/src/stores/agent-runs.ts
index 3d82770..328ec8f 100644
--- a/src/stores/agent-runs.ts
+++ b/src/stores/agent-runs.ts
@@ -114,7 +114,6 @@ function reduceTask(
{
id: p.taskId,
sessionId: "",
- agentId: p.agentId,
prompt: p.prompt ?? "",
state: "queued",
seq: p.seq,
@@ -122,7 +121,6 @@ function reduceTask(
};
const next: AgentTask = { ...base, seq: p.seq };
- if (p.agentId) next.agentId = p.agentId;
if (p.requestedBy !== undefined) next.requestedBy = p.requestedBy;
if (p.anchorMessageId !== undefined) next.anchorMessageId = p.anchorMessageId;
if (p.prompt) next.prompt = p.prompt;
@@ -223,29 +221,43 @@ export function tasksByAnchor(
return out;
}
-// tasksForAgent returns one agent's tasks in chronological order — the drawer
-// thread.
-export function tasksForAgent(
- runs: SessionAgentRuns | undefined,
- agentId: string,
-): AgentTask[] {
- return sessionTaskList(runs).filter((t) => t.agentId === agentId);
-}
-
-// queuePositions assigns a 1-based position to each queued task per agent, in
-// creation order. The server sends `position` on enqueue, but it goes stale
-// after promotions and is absent from snapshots — deriving it here keeps the
-// "#N in queue" label correct regardless. Keyed by task id.
+// queuePositions assigns a 1-based position to each queued task in creation
+// order (one serial queue per session). The server sends `position` on
+// enqueue, but it goes stale after promotions and is absent from snapshots —
+// deriving it here keeps the "#N in queue" label correct regardless. Keyed by
+// task id.
export function queuePositions(
runs: SessionAgentRuns | undefined,
): Record {
const out: Record = {};
- const perAgent: Record = {};
+ let n = 0;
for (const task of sessionTaskList(runs)) {
if (task.state !== "queued") continue;
- perAgent[task.agentId] = (perAgent[task.agentId] ?? 0) + 1;
- out[task.id] = perAgent[task.agentId];
+ n += 1;
+ out[task.id] = n;
}
return out;
}
+// DeuceStatus is the derived per-session agent status for the summary panel
+// and the drawer header — task state is the source of truth (the pre-013
+// agent_status events are gone).
+export type DeuceStatus = "idle" | "working" | "waiting";
+
+// statusOfTasks derives the agent's status from a task list: any task
+// awaiting input → "waiting"; else any running → "working"; else "idle".
+// Order-independent, so no sorted copy is made.
+export function statusOfTasks(tasks: Iterable): DeuceStatus {
+ let working = false;
+ for (const task of tasks) {
+ if (task.state === "awaiting_input") return "waiting";
+ if (task.state === "running") working = true;
+ }
+ return working ? "working" : "idle";
+}
+
+// deuceStatus is statusOfTasks over a session's reduced run state.
+export function deuceStatus(runs: SessionAgentRuns | undefined): DeuceStatus {
+ return statusOfTasks(runs ? Object.values(runs.tasks) : []);
+}
+
diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts
index 5b79547..ee3385c 100644
--- a/src/stores/session-store.ts
+++ b/src/stores/session-store.ts
@@ -6,7 +6,6 @@ import type {
Project,
Team,
Message,
- Agent,
ActivityItem,
TabType,
FileNode,
@@ -31,9 +30,7 @@ interface SessionState {
messages: Record;
activities: Record;
fileTrees: Record;
- thinkingAgents: Record;
workspaceLogs: Record;
- agentOutput: Record;
// Super Threads: per-session reduced agent-run (task/action) state.
agentRuns: Record;
@@ -42,15 +39,13 @@ interface SessionState {
activeTabMap: Record;
showLogs: boolean;
searchQuery: string;
- // Super Threads: which agent's per-agent thread drawer is open (right panel).
+ // Super Threads: which session's deuce thread drawer is open (right panel).
// Null when no drawer is open. Reset whenever the active session changes.
- openThread: { sessionId: string; agentId: string } | null;
+ openThread: { sessionId: string } | null;
// steerSender is registered by the WebSocket hook (use-websocket) so the
// store can forward steer/reply messages without ChatView needing its own
// socket. Null until the hook mounts.
- steerSender:
- | ((sessionId: string, agentId: string, message: string) => void)
- | null;
+ steerSender: ((sessionId: string, message: string) => void) | null;
// wsResubscribe is registered by the WebSocket hook so joining a session
// (without switching the active session) can start the live subscription
// immediately. Null until the hook mounts.
@@ -63,33 +58,22 @@ interface SessionState {
setSearchQuery: (query: string) => void;
clearUnread: (sessionId: string) => void;
addMessage: (message: Message) => void;
- setThinkingAgent: (sessionId: string, agentId: string) => void;
- clearThinkingAgent: (sessionId: string, agentId: string) => void;
- updateAgentStatus: (
- sessionId: string,
- agentId: string,
- status: Agent["status"],
- ) => void;
addActivity: (activity: ActivityItem) => void;
updateSessionPlan: (sessionId: string, content: string) => void;
updateSessionDescription: (sessionId: string, description: string) => void;
appendWorkspaceLog: (sessionId: string, line: string) => void;
- appendAgentOutput: (sessionId: string, output: { agentId: string; content: string; contentType: string }) => void;
- clearAgentOutput: (sessionId: string) => void;
applyAgentRunEvent: (
sessionId: string,
type: AgentRunEventType,
payload: TaskEventPayload | ActionEventPayload,
) => void;
fetchAgentRuns: (sessionId: string) => Promise;
- openAgentThread: (sessionId: string, agentId: string) => void;
+ openAgentThread: (sessionId: string) => void;
closeAgentThread: () => void;
setSteerSender: (
- fn:
- | ((sessionId: string, agentId: string, message: string) => void)
- | null,
+ fn: ((sessionId: string, message: string) => void) | null,
) => void;
- steer: (sessionId: string, agentId: string, message: string) => void;
+ steer: (sessionId: string, message: string) => void;
setShowLogs: (show: boolean) => void;
setWsResubscribe: (fn: ((sessionId: string) => void) | null) => void;
joinSession: (sessionId: string) => Promise;
@@ -122,9 +106,7 @@ export const useSessionStore = create((set, get) => ({
messages: {},
activities: {},
fileTrees: {},
- thinkingAgents: {},
workspaceLogs: {},
- agentOutput: {},
agentRuns: {},
activeSessionId: null,
@@ -232,43 +214,6 @@ export const useSessionStore = create((set, get) => ({
};
}),
- setThinkingAgent: (sessionId, agentId) =>
- set((state) => {
- const current = state.thinkingAgents[sessionId] ?? [];
- if (current.includes(agentId)) return state;
- return {
- thinkingAgents: {
- ...state.thinkingAgents,
- [sessionId]: [...current, agentId],
- },
- };
- }),
-
- clearThinkingAgent: (sessionId, agentId) =>
- set((state) => {
- const current = state.thinkingAgents[sessionId] ?? [];
- return {
- thinkingAgents: {
- ...state.thinkingAgents,
- [sessionId]: current.filter((id) => id !== agentId),
- },
- };
- }),
-
- updateAgentStatus: (sessionId, agentId, status) =>
- set((state) => ({
- sessions: state.sessions.map((s) =>
- s.id === sessionId
- ? {
- ...s,
- agents: s.agents.map((a) =>
- a.id === agentId ? { ...a, status } : a,
- ),
- }
- : s,
- ),
- })),
-
addActivity: (activity) =>
set((state) => {
const current = state.activities[activity.sessionId] ?? [];
@@ -306,22 +251,6 @@ export const useSessionStore = create((set, get) => ({
};
}),
- appendAgentOutput: (sessionId, output) =>
- set((state) => {
- const current = state.agentOutput[sessionId] ?? [];
- return {
- agentOutput: {
- ...state.agentOutput,
- [sessionId]: [...current, output],
- },
- };
- }),
-
- clearAgentOutput: (sessionId) =>
- set((state) => ({
- agentOutput: { ...state.agentOutput, [sessionId]: [] },
- })),
-
applyAgentRunEvent: (sessionId, type, payload) => {
const prev = get().agentRuns[sessionId] ?? emptyAgentRuns();
const { state: next, needsResync } = applyEvent(prev, type, payload);
@@ -346,20 +275,19 @@ export const useSessionStore = create((set, get) => ({
}
},
- openAgentThread: (sessionId, agentId) =>
- set({ openThread: { sessionId, agentId } }),
+ openAgentThread: (sessionId) => set({ openThread: { sessionId } }),
closeAgentThread: () => set({ openThread: null }),
setSteerSender: (fn) => set({ steerSender: fn }),
- steer: (sessionId, agentId, message) => {
+ steer: (sessionId, message) => {
const send = get().steerSender;
if (!send) {
console.warn("steer dropped: no WebSocket sender registered");
return;
}
- send(sessionId, agentId, message);
+ send(sessionId, message);
},
setShowLogs: (show) => set({ showLogs: show }),
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 3f194bf..9587ce8 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -80,18 +80,6 @@
--color-orange-emphasis: #bd561d;
--color-orange-muted: #5a1e02;
- /* ── Agent Colors ────────────────────────────────────────── */
- --color-agent-coder: #58a6ff;
- --color-agent-coder-muted: #0c2d6b;
- --color-agent-reviewer: #BE8FFF;
- --color-agent-reviewer-muted: #3c1e70;
- --color-agent-planner: #3fb950;
- --color-agent-planner-muted: #033a16;
- --color-agent-tester: #d29922;
- --color-agent-tester-muted: #4b2900;
- --color-agent-designer: #f778ba;
- --color-agent-designer-muted: #5e103e;
-
/* ── Typography ──────────────────────────────────────────── */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
@@ -355,6 +343,24 @@ button,
color: var(--color-foreground-subtle);
display: flex;
}
+.tc-stop {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 5px;
+ color: var(--color-danger);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+}
+.tc-stop:hover {
+ background: color-mix(in srgb, var(--color-danger) 14%, transparent);
+}
+.tc-stop svg {
+ fill: currentColor;
+}
.tc-live {
display: flex;
diff --git a/src/types/index.ts b/src/types/index.ts
index f71a684..69abacd 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -10,13 +10,10 @@ export type WorkspaceStatus =
| "deleting"
| "missing"
| "failed";
-export type AgentStatus = "idle" | "working" | "warming-up" | "error";
export type UserStatus = "online" | "offline";
export type MessageStatus = "sent" | "thinking" | "error";
export type AuthorType = "human" | "agent";
-export type AgentRole = string;
-
export interface Team {
id: string;
name: string;
@@ -49,7 +46,6 @@ export interface Session {
description: string;
projectId: string;
status: SessionStatus;
- agents: Agent[];
members: User[];
unreadCount: number;
createdAt: string;
@@ -65,7 +61,6 @@ export interface Message {
authorType: AuthorType;
content: string;
expandableContent?: ExpandableContent[];
- mentions: string[];
createdAt: string;
status: MessageStatus;
}
@@ -77,16 +72,10 @@ export interface ExpandableContent {
content: string;
}
-export interface Agent {
- id: string;
- name: string;
- role: AgentRole;
- color: string;
- colorMuted: string;
- status: AgentStatus;
- provider: string;
- model: string;
- description: string;
+// AgentSettings is the GET/PUT /api/agent shape: deuce's configurable,
+// GLOBAL system prompt. Identity (id/name/color) renders from the DEUCE
+// constant in src/lib/deuce.ts and is not part of the contract.
+export interface AgentSettings {
systemPrompt: string;
}
@@ -127,7 +116,6 @@ export interface ActivityItem {
type: "file-change" | "test-run" | "commit" | "agent-action";
description: string;
timestamp: string;
- agentId?: string;
metadata?: Record;
}
@@ -177,7 +165,6 @@ export type QuestionKind = "input" | "select" | "confirm";
export interface AgentTask {
id: string;
sessionId: string;
- agentId: string;
requestedBy?: string;
anchorMessageId?: string;
prompt: string;
@@ -202,7 +189,6 @@ export interface AgentTask {
export interface TaskEventPayload {
seq: number;
taskId: string;
- agentId: string;
requestedBy?: string;
anchorMessageId?: string;
prompt?: string;
@@ -218,7 +204,6 @@ export interface TaskEventPayload {
export interface ActionEventPayload {
seq: number;
taskId: string;
- agentId: string;
callId: string;
tool?: string;
arg?: string;