mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
14 Commits
agent/lamb
...
feat/chat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ba9cfb0b | ||
|
|
d3f7570177 | ||
|
|
34e452776b | ||
|
|
d779cbd183 | ||
|
|
10b6afc1ec | ||
|
|
4f58f0c8eb | ||
|
|
0399e387f8 | ||
|
|
a744cd4f45 | ||
|
|
bfa9bec8c4 | ||
|
|
bf71802451 | ||
|
|
09e6190400 | ||
|
|
0798b5f8bb | ||
|
|
e568896357 | ||
|
|
8748557c7b |
25
README.md
25
README.md
@@ -115,6 +115,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
|
||||
| **User model** | Multi-user teams with roles & permissions | Single board operator |
|
||||
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
|
||||
| **Deployment** | Cloud-first | Local-first |
|
||||
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
|
||||
| **Extensibility** | Skills system | Skills + Plugin system |
|
||||
|
||||
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
@@ -169,3 +184,13 @@ make dev
|
||||
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -117,6 +117,21 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
大功告成!你的 Agent 现在是团队的一员了。 🎉
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
|
||||
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
|
||||
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
|
||||
| **部署** | 云端优先 | 本地优先 |
|
||||
| **管理深度** | 轻量(Issue / Project / Labels) | 重度(组织架构 / 审批 / 预算) |
|
||||
| **扩展** | Skills 系统 | Skills + 插件系统 |
|
||||
|
||||
**简单来说:Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
@@ -157,3 +172,13 @@ make start
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -36,14 +36,19 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
|
||||
|
||||
## Reusable Skills
|
||||
|
||||
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
- Deployments
|
||||
- Migrations
|
||||
- Code reviews
|
||||
- Common patterns
|
||||
|
||||
Skills are shared across the workspace, so any agent (or human) can leverage them.
|
||||
Your skill library compounds over time. Local skills give individual agents their capabilities; workspace skills align the entire team.
|
||||
|
||||
## Multi-Workspace Support
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ import type {
|
||||
Attachment,
|
||||
ChatSession,
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
PendingChatTasksResponse,
|
||||
SendChatMessageResponse,
|
||||
Project,
|
||||
CreateProjectRequest,
|
||||
@@ -703,6 +705,18 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
|
||||
}
|
||||
|
||||
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
|
||||
return this.fetch(`/api/chat/pending-tasks`);
|
||||
}
|
||||
|
||||
async markChatSessionRead(sessionId: string): Promise<void> {
|
||||
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
async cancelTaskById(taskId: string): Promise<void> {
|
||||
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H } from "./store";
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
|
||||
|
||||
import type { createChatStore as CreateChatStoreFn } from "./store";
|
||||
|
||||
@@ -2,15 +2,67 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { chatKeys } from "./queries";
|
||||
import { createLogger } from "../logger";
|
||||
import type { ChatSession } from "../types";
|
||||
|
||||
const logger = createLogger("chat.mut");
|
||||
|
||||
export function useCreateChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { agent_id: string; title?: string }) =>
|
||||
api.createChatSession(data),
|
||||
mutationFn: (data: { agent_id: string; title?: string }) => {
|
||||
logger.info("createChatSession.start", { agent_id: data.agent_id, titleLength: data.title?.length ?? 0 });
|
||||
return api.createChatSession(data);
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
logger.info("createChatSession.success", { sessionId: session.id, agentId: session.agent_id });
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error("createChatSession.error", err);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the session's unread state server-side. Optimistically flips
|
||||
* has_unread to false in the cached lists so the FAB badge drops
|
||||
* immediately. The server broadcasts chat:session_read so other devices
|
||||
* also sync.
|
||||
*/
|
||||
export function useMarkChatSessionRead() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("markChatSessionRead.start", { sessionId });
|
||||
return api.markChatSessionRead(sessionId);
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
|
||||
|
||||
const clear = (old?: ChatSession[]) =>
|
||||
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), clear);
|
||||
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
@@ -23,7 +75,10 @@ export function useArchiveChatSession() {
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("archiveChatSession.start", { sessionId });
|
||||
return api.archiveChatSession(sessionId);
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
@@ -41,13 +96,16 @@ export function useArchiveChatSession() {
|
||||
),
|
||||
);
|
||||
|
||||
logger.debug("archiveChatSession.optimistic", { sessionId });
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("archiveChatSession.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: () => {
|
||||
onSettled: (_data, _err, sessionId) => {
|
||||
logger.debug("archiveChatSession.settled", { sessionId });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
|
||||
@@ -14,6 +14,11 @@ export const chatKeys = {
|
||||
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
|
||||
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
|
||||
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
|
||||
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
|
||||
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
|
||||
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
|
||||
/** Per-task execution messages — shared with issue agent cards. */
|
||||
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
|
||||
};
|
||||
|
||||
export function chatSessionsOptions(wsId: string) {
|
||||
@@ -49,3 +54,44 @@ export function chatMessagesOptions(sessionId: string) {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending task for a chat session — the "is something still running?" signal.
|
||||
* Refetched via WS invalidation in useRealtimeSync when chat:message / chat:done
|
||||
* / task:completed / task:failed arrive.
|
||||
*/
|
||||
export function pendingChatTaskOptions(sessionId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.pendingTask(sessionId),
|
||||
queryFn: () => api.getPendingChatTask(sessionId),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline for a single task — rendered by both the live chat view (while a
|
||||
* task is running) and AssistantMessage (for completed tasks). WS
|
||||
* `task:message` events seed this cache in real time via useRealtimeSync.
|
||||
*/
|
||||
export function taskMessagesOptions(taskId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.taskMessages(taskId),
|
||||
queryFn: () => api.listTaskMessages(taskId),
|
||||
enabled: !!taskId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate of in-flight chat tasks for the current user in this workspace.
|
||||
* Drives the FAB "running" indicator while the chat window is minimised —
|
||||
* no per-session query is active then, so we need this roll-up.
|
||||
*/
|
||||
export function pendingChatTasksOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.pendingTasks(wsId),
|
||||
queryFn: () => api.listPendingChatTasks(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,54 @@
|
||||
import { create } from "zustand";
|
||||
import type { StorageAdapter } from "../types";
|
||||
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const logger = createLogger("chat.store");
|
||||
|
||||
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
||||
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
const DRAFT_KEY = "multica:chat:draft";
|
||||
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
|
||||
const DRAFTS_KEY = "multica:chat:drafts";
|
||||
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
||||
export const DRAFT_NEW_SESSION = "__new__";
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
|
||||
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string, string>) {
|
||||
// Prune empty entries so the blob doesn't grow unbounded.
|
||||
const pruned: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(drafts)) {
|
||||
if (v) pruned[k] = v;
|
||||
}
|
||||
if (Object.keys(pruned).length === 0) {
|
||||
storage.removeItem(key);
|
||||
} else {
|
||||
storage.setItem(key, JSON.stringify(pruned));
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_MIN_W = 360;
|
||||
export const CHAT_MIN_H = 480;
|
||||
export const CHAT_DEFAULT_W = 420;
|
||||
export const CHAT_DEFAULT_H = 600;
|
||||
|
||||
/**
|
||||
* Kept as a public type because existing consumers (chat-message-list,
|
||||
* views/chat types) import it. Items themselves no longer live in the
|
||||
* store — they flow through the React Query cache keyed by task id.
|
||||
*/
|
||||
export interface ChatTimelineItem {
|
||||
seq: number;
|
||||
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
|
||||
@@ -26,11 +61,10 @@ export interface ChatTimelineItem {
|
||||
export interface ChatState {
|
||||
isOpen: boolean;
|
||||
activeSessionId: string | null;
|
||||
pendingTaskId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
timelineItems: ChatTimelineItem[];
|
||||
inputDraft: string;
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, string>;
|
||||
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
|
||||
chatWidth: number;
|
||||
chatHeight: number;
|
||||
@@ -38,13 +72,11 @@ export interface ChatState {
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setPendingTask: (taskId: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
addTimelineItem: (item: ChatTimelineItem) => void;
|
||||
clearTimeline: () => void;
|
||||
setInputDraft: (draft: string) => void;
|
||||
clearInputDraft: () => void;
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
@@ -62,20 +94,26 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
return wsId ? `${base}:${wsId}` : base;
|
||||
};
|
||||
|
||||
const store = create<ChatState>((set) => ({
|
||||
const store = create<ChatState>((set, get) => ({
|
||||
isOpen: false,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
pendingTaskId: null,
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
timelineItems: [],
|
||||
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
|
||||
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
|
||||
setOpen: (open) => set({ isOpen: open }),
|
||||
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
|
||||
setOpen: (open) => {
|
||||
logger.debug("setOpen", { from: get().isOpen, to: open });
|
||||
set({ isOpen: open });
|
||||
},
|
||||
toggle: () => {
|
||||
const next = !get().isOpen;
|
||||
logger.debug("toggle", { to: next });
|
||||
set({ isOpen: next });
|
||||
},
|
||||
setActiveSession: (id) => {
|
||||
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
|
||||
if (id) {
|
||||
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
|
||||
} else {
|
||||
@@ -83,35 +121,36 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
}
|
||||
set({ activeSessionId: id });
|
||||
},
|
||||
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
|
||||
setSelectedAgentId: (id) => {
|
||||
logger.info("setSelectedAgentId", { from: get().selectedAgentId, to: id });
|
||||
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
setInputDraft: (draft) => {
|
||||
if (draft) {
|
||||
storage.setItem(wsKey(DRAFT_KEY), draft);
|
||||
} else {
|
||||
storage.removeItem(wsKey(DRAFT_KEY));
|
||||
setShowHistory: (show) => {
|
||||
logger.debug("setShowHistory", { to: show });
|
||||
set({ showHistory: show });
|
||||
},
|
||||
setInputDraft: (sessionId, draft) => {
|
||||
// Debug level — onUpdate fires on every keystroke.
|
||||
logger.debug("setInputDraft", { sessionId, length: draft.length });
|
||||
const next = { ...get().inputDrafts, [sessionId]: draft };
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
clearInputDraft: (sessionId) => {
|
||||
const current = get().inputDrafts;
|
||||
if (!(sessionId in current)) {
|
||||
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
|
||||
return;
|
||||
}
|
||||
set({ inputDraft: draft });
|
||||
logger.info("clearInputDraft", { sessionId });
|
||||
const next = { ...current };
|
||||
delete next[sessionId];
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
clearInputDraft: () => {
|
||||
storage.removeItem(wsKey(DRAFT_KEY));
|
||||
set({ inputDraft: "" });
|
||||
},
|
||||
addTimelineItem: (item) =>
|
||||
set((s) => {
|
||||
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
|
||||
return {
|
||||
timelineItems: [...s.timelineItems, item].sort(
|
||||
(a, b) => a.seq - b.seq,
|
||||
),
|
||||
};
|
||||
}),
|
||||
clearTimeline: () => set({ timelineItems: [] }),
|
||||
setChatSize: (w, h) => {
|
||||
logger.debug("setChatSize", { w, h });
|
||||
storage.setItem(CHAT_WIDTH_KEY, String(w));
|
||||
storage.setItem(CHAT_HEIGHT_KEY, String(h));
|
||||
// Dragging = user chose a manual size → exit expanded mode
|
||||
@@ -119,6 +158,7 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
set({ chatWidth: w, chatHeight: h, isExpanded: false });
|
||||
},
|
||||
setExpanded: (expanded) => {
|
||||
logger.info("setExpanded", { to: expanded });
|
||||
if (expanded) {
|
||||
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
|
||||
} else {
|
||||
@@ -129,11 +169,20 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
}));
|
||||
|
||||
registerForWorkspaceRehydration(() => {
|
||||
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
|
||||
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
|
||||
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
|
||||
logger.info("workspace rehydration", {
|
||||
prevSession: store.getState().activeSessionId,
|
||||
nextSession,
|
||||
prevAgent: store.getState().selectedAgentId,
|
||||
nextAgent,
|
||||
draftCount: Object.keys(nextDrafts).length,
|
||||
});
|
||||
store.setState({
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
|
||||
timelineItems: [],
|
||||
activeSessionId: nextSession,
|
||||
selectedAgentId: nextAgent,
|
||||
inputDrafts: nextDrafts,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { IssueStatus } from "../../types";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
@@ -13,25 +12,22 @@ const MAX_RECENT_ISSUES = 20;
|
||||
|
||||
export interface RecentIssueEntry {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
visitedAt: number;
|
||||
}
|
||||
|
||||
interface RecentIssuesState {
|
||||
items: RecentIssueEntry[];
|
||||
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
|
||||
recordVisit: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
recordVisit: (entry) =>
|
||||
recordVisit: (id) =>
|
||||
set((state) => {
|
||||
const filtered = state.items.filter((i) => i.id !== entry.id);
|
||||
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
|
||||
const filtered = state.items.filter((i) => i.id !== id);
|
||||
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
|
||||
return {
|
||||
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ describe("clearWorkspaceStorage", () => {
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica_my_issues_view:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:selectedAgentId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(6);
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:drafts:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:expanded:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ const WORKSPACE_SCOPED_KEYS = [
|
||||
"multica_my_issues_view",
|
||||
"multica:chat:selectedAgentId",
|
||||
"multica:chat:activeSessionId",
|
||||
"multica:chat:drafts",
|
||||
"multica:chat:expanded",
|
||||
];
|
||||
|
||||
/** Remove all workspace-scoped storage entries for the given workspace. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
@@ -39,8 +40,14 @@ import type {
|
||||
IssueReactionRemovedPayload,
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
TaskMessagePayload,
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
ChatDonePayload,
|
||||
} from "../types";
|
||||
|
||||
const chatWsLogger = createLogger("chat.ws");
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
|
||||
export interface RealtimeSyncStores {
|
||||
@@ -133,6 +140,9 @@ export function useRealtimeSync(
|
||||
"issue_reaction:added", "issue_reaction:removed",
|
||||
"subscriber:added", "subscriber:removed",
|
||||
"daemon:heartbeat",
|
||||
// Chat / task events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read",
|
||||
"task:message", "task:completed", "task:failed",
|
||||
]);
|
||||
|
||||
const unsubAny = ws.onAny((msg) => {
|
||||
@@ -283,6 +293,103 @@ export function useRealtimeSync(
|
||||
}
|
||||
});
|
||||
|
||||
// --- Chat / task events (global, survives ChatWindow unmount) ---
|
||||
//
|
||||
// Single source of truth: the Query cache. No Zustand writes here — the
|
||||
// earlier mirror caused a race where the cache and store disagreed
|
||||
// during the invalidate → refetch window and the UI rendered duplicates.
|
||||
//
|
||||
// task:message is written directly into the task-messages cache so the
|
||||
// live timeline updates in place. chat:message / chat:done /
|
||||
// task:completed / task:failed invalidate messages + pending-task so the
|
||||
// DB remains authoritative.
|
||||
|
||||
const unsubTaskMessage = ws.on("task:message", (p) => {
|
||||
const payload = p as TaskMessagePayload;
|
||||
qc.setQueryData<TaskMessagePayload[]>(
|
||||
["task-messages", payload.task_id],
|
||||
(old = []) => {
|
||||
if (old.some((m) => m.seq === payload.seq)) return old;
|
||||
return [...old, payload].sort((a, b) => a.seq - b.seq);
|
||||
},
|
||||
);
|
||||
chatWsLogger.debug("task:message (global)", {
|
||||
task_id: payload.task_id,
|
||||
seq: payload.seq,
|
||||
type: payload.type,
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers reused by chat lifecycle handlers.
|
||||
const invalidatePendingAggregate = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
if (id) qc.invalidateQueries({ queryKey: chatKeys.pendingTasks(id) });
|
||||
};
|
||||
const invalidateSessionLists = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
if (id) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
|
||||
}
|
||||
};
|
||||
|
||||
const unsubChatMessage = ws.on("chat:message", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:message (global)", { chat_session_id: payload.chat_session_id });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubChatDone = ws.on("chat:done", (p) => {
|
||||
const payload = p as ChatDonePayload;
|
||||
chatWsLogger.info("chat:done (global)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// Assistant message was just written and task flipped out of 'running'.
|
||||
// Clear pending-task cache immediately so the live-timeline-vs-assistant
|
||||
// race window collapses to zero — the subsequent refetch will confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
// Assistant message just landed → has_unread may have flipped to true.
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
const unsubTaskCompleted = ws.on("task:completed", (p) => {
|
||||
const payload = p as TaskCompletedPayload;
|
||||
if (!payload.chat_session_id) return; // issue tasks handled elsewhere
|
||||
chatWsLogger.info("task:completed (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubTaskFailed = ws.on("task:failed", (p) => {
|
||||
const payload = p as TaskFailedPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
chatWsLogger.warn("task:failed (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// No new message; just flip the pending signal.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubChatSessionRead = ws.on("chat:session_read", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:session_read (global)", payload);
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAny();
|
||||
unsubIssueUpdated();
|
||||
@@ -302,6 +409,12 @@ export function useRealtimeSync(
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
unsubTaskMessage();
|
||||
unsubChatMessage();
|
||||
unsubChatDone();
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
@@ -5,10 +5,22 @@ export interface ChatSession {
|
||||
creator_id: string;
|
||||
title: string;
|
||||
status: "active" | "archived";
|
||||
/** True when the session has any unread assistant replies. List-only. */
|
||||
has_unread: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PendingChatTaskItem {
|
||||
task_id: string;
|
||||
status: string;
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface PendingChatTasksResponse {
|
||||
tasks: PendingChatTaskItem[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
chat_session_id: string;
|
||||
@@ -22,3 +34,12 @@ export interface SendChatMessageResponse {
|
||||
message_id: string;
|
||||
task_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /api/chat/sessions/{id}/pending-task.
|
||||
* Both fields are absent when the session has no in-flight task.
|
||||
*/
|
||||
export interface ChatPendingTask {
|
||||
task_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export type WSEventType =
|
||||
| "issue_reaction:removed"
|
||||
| "chat:message"
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
@@ -170,6 +171,7 @@ export interface ActivityCreatedPayload {
|
||||
export interface TaskMessagePayload {
|
||||
task_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
seq: number;
|
||||
type: "text" | "thinking" | "tool_use" | "tool_result" | "error";
|
||||
tool?: string;
|
||||
@@ -182,6 +184,7 @@ export interface TaskCompletedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -189,6 +192,7 @@ export interface TaskFailedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -196,6 +200,7 @@ export interface TaskCancelledPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -239,6 +244,10 @@ export interface ChatDonePayload {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ChatSessionReadPayload {
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface ProjectCreatedPayload {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export type { IssueSubscriber } from "./subscriber";
|
||||
export type * from "./events";
|
||||
export type * from "./api";
|
||||
export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
|
||||
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
|
||||
@@ -115,7 +115,11 @@ function createComponents(
|
||||
const id = mentionMatch[2]
|
||||
|
||||
if (renderMention) {
|
||||
return <>{renderMention({ type, id })}</>
|
||||
// Let the custom renderer opt out for types it doesn't handle
|
||||
// by returning null/undefined — we then fall through to the
|
||||
// default styled span so nothing ever disappears silently.
|
||||
const rendered = renderMention({ type, id })
|
||||
if (rendered) return <>{rendered}</>
|
||||
}
|
||||
|
||||
// Fallback: render as a simple styled span
|
||||
|
||||
@@ -25,6 +25,24 @@
|
||||
animation: entrance-spin 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Chat FAB: gentle color + border tint while a chat task is running.
|
||||
* Keeps the ring at the same thickness — only hue shifts towards brand
|
||||
* at half-cycle, no outer glow. */
|
||||
@keyframes chat-impulse {
|
||||
0%, 100% {
|
||||
color: var(--muted-foreground);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||
}
|
||||
50% {
|
||||
color: var(--brand);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--brand) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-chat-impulse {
|
||||
animation: chat-impulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, FileText, Trash2 } from "lucide-react";
|
||||
import { Plus, FileText, Trash2, Info } from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -65,7 +65,7 @@ export function SkillsTab({
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Skills</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Reusable skills assigned to this agent. Manage skills on the Skills page.
|
||||
Workspace skills assigned to this agent.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -79,12 +79,19 @@ export function SkillsTab({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-info/20 bg-info/5 px-3 py-2.5">
|
||||
<Info className="h-3.5 w-3.5 shrink-0 text-info mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Local runtime skills (from your CLI's skills directory) are always available automatically — no need to add them here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{agent.skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
||||
<FileText className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No skills assigned</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Add skills from the workspace to this agent.
|
||||
Add workspace skills to share team knowledge with this agent. Local skills are already used automatically.
|
||||
</p>
|
||||
{availableSkills.length > 0 && (
|
||||
<Button
|
||||
|
||||
@@ -1,28 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
export function ChatFab() {
|
||||
const wsId = useWorkspaceId();
|
||||
const isOpen = useChatStore((s) => s.isOpen);
|
||||
const toggle = useChatStore((s) => s.toggle);
|
||||
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
|
||||
const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
|
||||
|
||||
if (isOpen) return null;
|
||||
|
||||
const unreadSessionCount = sessions.filter((s) => s.has_unread).length;
|
||||
const isRunning = (pending?.tasks ?? []).length > 0;
|
||||
|
||||
const handleClick = () => {
|
||||
logger.info("fab.click (open chat)", { unreadSessionCount, isRunning });
|
||||
toggle();
|
||||
};
|
||||
|
||||
// Tooltip text communicates the state that isn't carried by the icon/badge.
|
||||
const tooltip = isRunning
|
||||
? "Multica is working..."
|
||||
: unreadSessionCount > 0
|
||||
? `${unreadSessionCount} unread ${unreadSessionCount === 1 ? "chat" : "chats"}`
|
||||
: "Ask Multica";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={toggle}
|
||||
className="absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95",
|
||||
// Impulse the button itself while a chat task is running — no
|
||||
// outer ring to keep things calm.
|
||||
isRunning && "animate-chat-impulse",
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
{unreadSessionCount > 0 && (
|
||||
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex min-w-4 h-4 items-center justify-center rounded-full bg-brand px-1 text-xs font-semibold leading-none text-background">
|
||||
{unreadSessionCount > 9 ? "9+" : unreadSessionCount}
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={10}>Ask Multica</TooltipContent>
|
||||
<TooltipContent side="top" sideOffset={10}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
onStop?: () => void;
|
||||
isRunning?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Name of the currently selected agent, used in the placeholder. */
|
||||
agentName?: string;
|
||||
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
|
||||
leftAdornment?: ReactNode;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, onStop, isRunning, disabled }: ChatInputProps) {
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onStop,
|
||||
isRunning,
|
||||
disabled,
|
||||
agentName,
|
||||
leftAdornment,
|
||||
}: ChatInputProps) {
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const inputDraft = useChatStore((s) => s.inputDraft);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
|
||||
// Scope the new-chat draft by agent:
|
||||
// 1. Switching agents while composing a brand-new chat gives each
|
||||
// agent its own draft (no cross-agent leakage).
|
||||
// 2. Tiptap's Placeholder extension is only applied at mount; this
|
||||
// key changes on agent switch so the editor remounts and the
|
||||
// `Tell {agent} what to do…` placeholder refreshes.
|
||||
const draftKey =
|
||||
activeSessionId ?? `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
|
||||
// Select a primitive — empty-string fallback keeps referential stability.
|
||||
const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? "");
|
||||
const setInputDraft = useChatStore((s) => s.setInputDraft);
|
||||
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
|
||||
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
|
||||
|
||||
const handleSend = () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || isRunning || disabled) return;
|
||||
if (!content || isRunning || disabled) {
|
||||
logger.debug("input.send skipped", {
|
||||
emptyContent: !content,
|
||||
isRunning,
|
||||
disabled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Capture draft key BEFORE onSend — creating a new session mutates
|
||||
// activeSessionId synchronously, so reading it after onSend would point
|
||||
// at the new session and leave the old draft orphaned.
|
||||
const keyAtSend = draftKey;
|
||||
logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend });
|
||||
onSend(content);
|
||||
editorRef.current?.clearContent();
|
||||
clearInputDraft();
|
||||
clearInputDraft(keyAtSend);
|
||||
setIsEmpty(true);
|
||||
};
|
||||
|
||||
const placeholder = disabled
|
||||
? "This session is archived"
|
||||
: agentName
|
||||
? `Tell ${agentName} what to do…`
|
||||
: "Tell me what to do…";
|
||||
|
||||
return (
|
||||
<div className="p-2 pt-0">
|
||||
<div className="relative flex min-h-16 max-h-40 flex-col rounded-lg bg-card pb-8 border-1 border-border transition-colors focus-within:border-brand">
|
||||
<div className="px-5 pb-3 pt-0">
|
||||
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
// Remount the editor when the active session changes so its
|
||||
// uncontrolled defaultValue picks up the new session's draft.
|
||||
key={draftKey}
|
||||
ref={editorRef}
|
||||
defaultValue={inputDraft}
|
||||
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => {
|
||||
setIsEmpty(!md.trim());
|
||||
setInputDraft(md);
|
||||
setInputDraft(draftKey, md);
|
||||
}}
|
||||
onSubmit={handleSend}
|
||||
debounceMs={100}
|
||||
// Chat is short-form — the floating formatting toolbar is
|
||||
// more distraction than feature here.
|
||||
showBubbleMenu={false}
|
||||
// Enter sends; Shift-Enter inserts a hard break.
|
||||
submitOnEnter
|
||||
/>
|
||||
</div>
|
||||
{leftAdornment && (
|
||||
<div className="absolute bottom-1.5 left-2 flex items-center">
|
||||
{leftAdornment}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
<SubmitButton
|
||||
onClick={handleSend}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { api } from "@multica/core/api";
|
||||
import { taskMessagesOptions } from "@multica/core/chat/queries";
|
||||
import { Markdown } from "@multica/views/common/markdown";
|
||||
import type { ChatMessage, TaskMessagePayload } from "@multica/core/types";
|
||||
import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
@@ -20,51 +20,113 @@ import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
timelineItems: ChatTimelineItem[];
|
||||
/** When set, streams the live timeline for this task from task-messages cache. */
|
||||
pendingTaskId: string | null;
|
||||
isWaiting: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessageList({
|
||||
messages,
|
||||
timelineItems,
|
||||
pendingTaskId,
|
||||
isWaiting,
|
||||
}: ChatMessageListProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(scrollRef);
|
||||
useAutoScroll(scrollRef);
|
||||
|
||||
const hasTimeline = timelineItems.length > 0;
|
||||
// Once the assistant message for this pending task has landed in the
|
||||
// messages list, AssistantMessage owns its rendering — suppress the live
|
||||
// timeline to avoid rendering the same content in two places during the
|
||||
// invalidate → refetch window.
|
||||
const pendingAlreadyPersisted = !!pendingTaskId && messages.some(
|
||||
(m) => m.role === "assistant" && m.task_id === pendingTaskId,
|
||||
);
|
||||
|
||||
// Live timeline for the in-flight task. useRealtimeSync keeps this cache
|
||||
// current via setQueryData on task:message events.
|
||||
const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted;
|
||||
const { data: liveTaskMessages } = useQuery({
|
||||
...taskMessagesOptions(pendingTaskId ?? ""),
|
||||
enabled: showLiveTimeline,
|
||||
});
|
||||
const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem);
|
||||
const hasLive = showLiveTimeline && liveTimeline.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={fadeStyle}
|
||||
className="flex-1 overflow-y-auto px-4 py-3 space-y-4"
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{/* Live streaming timeline */}
|
||||
{hasTimeline && (
|
||||
<div className="w-full space-y-1.5">
|
||||
<TimelineView items={timelineItems} />
|
||||
</div>
|
||||
)}
|
||||
{isWaiting && !hasTimeline && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<div ref={scrollRef} style={fadeStyle} className="flex-1 overflow-y-auto">
|
||||
{/* Inner container matches issue / project detail width convention
|
||||
* (max-w-4xl + mx-auto) so switching between chat and content
|
||||
* views doesn't jolt the reading width. px-5 is a touch tighter
|
||||
* than issue-detail's px-8 because the chat window can be narrow. */}
|
||||
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{hasLive && (
|
||||
<div className="w-full space-y-1.5">
|
||||
<TimelineView items={liveTimeline} />
|
||||
</div>
|
||||
)}
|
||||
{isWaiting && !hasLive && !pendingAlreadyPersisted && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder shown while `chat_message` for a session is being fetched
|
||||
* (initial refresh, or switching to an un-cached session). Shape roughly
|
||||
* mirrors an assistant → user → assistant exchange so the window doesn't
|
||||
* shift under the user when real messages arrive.
|
||||
*/
|
||||
export function ChatMessageSkeleton() {
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 w-3/4 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3.5 w-1/2 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="h-8 w-48 rounded-2xl bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 w-2/3 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3.5 w-5/6 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3.5 w-1/3 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toTimelineItem(m: TaskMessagePayload): ChatTimelineItem {
|
||||
return {
|
||||
seq: m.seq,
|
||||
type: m.type,
|
||||
tool: m.tool,
|
||||
content: m.content,
|
||||
input: m.input,
|
||||
output: m.output,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Message bubbles ─────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] break-words">
|
||||
{/* User messages are authored as markdown in ContentEditor, so
|
||||
* render them through the same pipeline as assistant replies.
|
||||
* Neutralise prose's leading/trailing margin so single-line
|
||||
* bubbles stay as compact as the plain-text version used to. */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -80,24 +142,15 @@ function AssistantMessage({
|
||||
}) {
|
||||
const taskId = message.task_id;
|
||||
|
||||
// Always fetch task messages for assistant messages with a task_id
|
||||
// Use the shared taskMessagesOptions so this cache entry is the same one
|
||||
// seeded by useRealtimeSync during task execution — zero refetch when the
|
||||
// task finishes, since WS already populated it.
|
||||
const { data: taskMessages } = useQuery({
|
||||
queryKey: ["task-messages", taskId],
|
||||
queryFn: () => api.listTaskMessages(taskId!),
|
||||
...taskMessagesOptions(taskId ?? ""),
|
||||
enabled: !!taskId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(
|
||||
(m: TaskMessagePayload) => ({
|
||||
seq: m.seq,
|
||||
type: m.type,
|
||||
tool: m.tool,
|
||||
content: m.content,
|
||||
input: m.input,
|
||||
output: m.output,
|
||||
}),
|
||||
);
|
||||
const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(toTimelineItem);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-1.5">
|
||||
|
||||
@@ -10,14 +10,15 @@ import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { allChatSessionsOptions } from "@multica/core/chat/queries";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import type { ChatSession, Agent } from "@multica/core/types";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
export function ChatSessionHistory() {
|
||||
const wsId = useWorkspaceId();
|
||||
const setShowHistory = useChatStore((s) => s.setShowHistory);
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const clearTimeline = useChatStore((s) => s.clearTimeline);
|
||||
const setPendingTask = useChatStore((s) => s.setPendingTask);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
|
||||
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
|
||||
@@ -26,9 +27,15 @@ export function ChatSessionHistory() {
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]));
|
||||
|
||||
const handleSelectSession = (session: ChatSession) => {
|
||||
logger.info("selectSession", {
|
||||
from: activeSessionId,
|
||||
to: session.id,
|
||||
agentId: session.agent_id,
|
||||
status: session.status,
|
||||
});
|
||||
// Changing activeSessionId flips the query keys for messages +
|
||||
// pending-task; no manual clear needed.
|
||||
setActiveSession(session.id);
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
setShowHistory(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Minus, Maximize2, Minimize2, Send, ChevronDown, Bot, Plus, History } from "lucide-react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
@@ -24,43 +24,53 @@ import {
|
||||
chatSessionsOptions,
|
||||
allChatSessionsOptions,
|
||||
chatMessagesOptions,
|
||||
pendingChatTaskOptions,
|
||||
chatKeys,
|
||||
} from "@multica/core/chat/queries";
|
||||
import { useCreateChatSession } from "@multica/core/chat/mutations";
|
||||
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { ChatMessageList } from "./chat-message-list";
|
||||
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ChatSessionHistory } from "./chat-session-history";
|
||||
import { ChatResizeHandles } from "./chat-resize-handles";
|
||||
import { useChatResize } from "./use-chat-resize";
|
||||
import { useWS } from "@multica/core/realtime";
|
||||
import type { TaskMessagePayload, ChatDonePayload, Agent, ChatMessage } from "@multica/core/types";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
|
||||
|
||||
const uiLogger = createLogger("chat.ui");
|
||||
const apiLogger = createLogger("chat.api");
|
||||
|
||||
export function ChatWindow() {
|
||||
const wsId = useWorkspaceId();
|
||||
const isOpen = useChatStore((s) => s.isOpen);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
const pendingTaskId = useChatStore((s) => s.pendingTaskId);
|
||||
const timelineItems = useChatStore((s) => s.timelineItems);
|
||||
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
|
||||
const setOpen = useChatStore((s) => s.setOpen);
|
||||
const showHistory = useChatStore((s) => s.showHistory);
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const setPendingTask = useChatStore((s) => s.setPendingTask);
|
||||
const addTimelineItem = useChatStore((s) => s.addTimelineItem);
|
||||
const clearTimeline = useChatStore((s) => s.clearTimeline);
|
||||
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
|
||||
const setShowHistory = useChatStore((s) => s.setShowHistory);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
|
||||
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
|
||||
const { data: rawMessages } = useQuery(
|
||||
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
|
||||
chatMessagesOptions(activeSessionId ?? ""),
|
||||
);
|
||||
// When no active session, always show empty — don't use stale cache
|
||||
const messages = activeSessionId ? rawMessages ?? [] : [];
|
||||
// Skeleton only shows for an un-cached session fetch. Cached switches
|
||||
// return data synchronously — no flash. `enabled: false` (new chat)
|
||||
// keeps isLoading false so the starter prompts aren't hidden.
|
||||
const showSkeleton = !!activeSessionId && messagesLoading;
|
||||
|
||||
// Server-authoritative pending task. Survives refresh / reopen / session
|
||||
// switch because it's keyed on sessionId in the Query cache; WS events
|
||||
// (chat:message / chat:done / task:*) keep it invalidated in real time.
|
||||
//
|
||||
// This is the SOLE source for pendingTaskId — no mirror in the store.
|
||||
const { data: pendingTask } = useQuery(
|
||||
pendingChatTaskOptions(activeSessionId ?? ""),
|
||||
);
|
||||
const pendingTaskId = pendingTask?.task_id ?? null;
|
||||
|
||||
// Check if current session is archived
|
||||
const currentSession = activeSessionId
|
||||
@@ -70,6 +80,7 @@ export function ChatWindow() {
|
||||
|
||||
const qc = useQueryClient();
|
||||
const createSession = useCreateChatSession();
|
||||
const markRead = useMarkChatSessionRead();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
const memberRole = currentMember?.role;
|
||||
@@ -83,87 +94,82 @@ export function ChatWindow() {
|
||||
availableAgents[0] ??
|
||||
null;
|
||||
|
||||
// Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
|
||||
// fires on layout mount (login / workspace switch / fresh page load).
|
||||
useEffect(() => {
|
||||
uiLogger.info("ChatWindow mount", {
|
||||
isOpen,
|
||||
activeSessionId,
|
||||
pendingTaskId,
|
||||
selectedAgentId,
|
||||
wsId,
|
||||
});
|
||||
return () => {
|
||||
uiLogger.info("ChatWindow unmount", {
|
||||
activeSessionId,
|
||||
pendingTaskId,
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount
|
||||
}, []);
|
||||
|
||||
// Auto-restore most recent active session from server (only once on mount)
|
||||
const didRestoreRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didRestoreRef.current) return;
|
||||
didRestoreRef.current = true;
|
||||
if (activeSessionId || sessions.length === 0) return;
|
||||
if (activeSessionId || sessions.length === 0) {
|
||||
uiLogger.debug("restore session skipped", {
|
||||
reason: activeSessionId ? "already has session" : "no sessions",
|
||||
activeSessionId,
|
||||
sessionCount: sessions.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const latest = sessions.find((s) => s.status === "active");
|
||||
if (latest) {
|
||||
uiLogger.info("restore session on mount", { sessionId: latest.id });
|
||||
setActiveSession(latest.id);
|
||||
} else {
|
||||
uiLogger.debug("restore session: no active session found");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
|
||||
}, [sessions]);
|
||||
|
||||
// Use ref for pendingTaskId so WS handlers always see the latest value
|
||||
// without needing to re-subscribe on every change.
|
||||
const pendingTaskRef = useRef<string | null>(pendingTaskId);
|
||||
pendingTaskRef.current = pendingTaskId;
|
||||
|
||||
const { subscribe } = useWS();
|
||||
// WS events are handled globally in useRealtimeSync — the query cache
|
||||
// stays current even when this window is closed. See packages/core/realtime/.
|
||||
|
||||
// Auto mark-as-read whenever the user is looking at a session with unread
|
||||
// state: window open + a session active + has_unread → PATCH.
|
||||
// has_unread comes from the list query; WS handlers invalidate it on
|
||||
// chat:done so a reply arriving while the user watches triggers this
|
||||
// effect again and is instantly cleared.
|
||||
const currentHasUnread =
|
||||
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
|
||||
useEffect(() => {
|
||||
// Returns true if the event was for our pending task and was handled.
|
||||
// Caller still decides whether to invalidate cache (chat:done / completed do; failed doesn't).
|
||||
const matchesPending = (taskId: string) =>
|
||||
!!pendingTaskRef.current && taskId === pendingTaskRef.current;
|
||||
|
||||
const finalizePending = (invalidateCache: boolean) => {
|
||||
if (invalidateCache) {
|
||||
const sid = useChatStore.getState().activeSessionId;
|
||||
if (sid) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sid) });
|
||||
}
|
||||
}
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
};
|
||||
|
||||
const unsubMessage = subscribe("task:message", (payload) => {
|
||||
const p = payload as TaskMessagePayload;
|
||||
if (!matchesPending(p.task_id)) return;
|
||||
addTimelineItem({
|
||||
seq: p.seq,
|
||||
type: p.type,
|
||||
tool: p.tool,
|
||||
content: p.content,
|
||||
input: p.input,
|
||||
output: p.output,
|
||||
});
|
||||
});
|
||||
|
||||
const unsubDone = subscribe("chat:done", (payload) => {
|
||||
const p = payload as ChatDonePayload;
|
||||
if (!matchesPending(p.task_id)) return;
|
||||
finalizePending(true);
|
||||
});
|
||||
|
||||
const unsubCompleted = subscribe("task:completed", (payload) => {
|
||||
const p = payload as { task_id: string };
|
||||
if (!matchesPending(p.task_id)) return;
|
||||
finalizePending(true);
|
||||
});
|
||||
|
||||
const unsubFailed = subscribe("task:failed", (payload) => {
|
||||
const p = payload as { task_id: string };
|
||||
if (!matchesPending(p.task_id)) return;
|
||||
finalizePending(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubMessage();
|
||||
unsubDone();
|
||||
unsubCompleted();
|
||||
unsubFailed();
|
||||
};
|
||||
}, [subscribe, addTimelineItem, clearTimeline, setPendingTask, qc]);
|
||||
if (!isOpen || !activeSessionId) return;
|
||||
if (!currentHasUnread) return;
|
||||
uiLogger.info("auto markRead", { sessionId: activeSessionId });
|
||||
markRead.mutate(activeSessionId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
|
||||
}, [isOpen, activeSessionId, currentHasUnread]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string) => {
|
||||
if (!activeAgent) return;
|
||||
if (!activeAgent) {
|
||||
apiLogger.warn("sendChatMessage skipped: no active agent");
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId = activeSessionId;
|
||||
const isNewSession = !sessionId;
|
||||
|
||||
apiLogger.info("sendChatMessage.start", {
|
||||
sessionId,
|
||||
isNewSession,
|
||||
agentId: activeAgent.id,
|
||||
contentLength: content.length,
|
||||
});
|
||||
|
||||
if (!sessionId) {
|
||||
const session = await createSession.mutateAsync({
|
||||
@@ -187,9 +193,20 @@ export function ChatWindow() {
|
||||
chatKeys.messages(sessionId),
|
||||
(old) => (old ? [...old, optimistic] : [optimistic]),
|
||||
);
|
||||
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
|
||||
|
||||
const result = await api.sendChatMessage(sessionId, content);
|
||||
setPendingTask(result.task_id);
|
||||
apiLogger.info("sendChatMessage.success", {
|
||||
sessionId,
|
||||
messageId: result.message_id,
|
||||
taskId: result.task_id,
|
||||
});
|
||||
// Seed pending-task optimistically so the spinner shows instantly —
|
||||
// the WS chat:message handler will invalidate + refetch to confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(sessionId), {
|
||||
task_id: result.task_id,
|
||||
status: "queued",
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
},
|
||||
[
|
||||
@@ -197,38 +214,88 @@ export function ChatWindow() {
|
||||
activeAgent,
|
||||
createSession,
|
||||
setActiveSession,
|
||||
setPendingTask,
|
||||
qc,
|
||||
],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!pendingTaskId) return;
|
||||
if (!pendingTaskId) {
|
||||
apiLogger.debug("cancelTask skipped: no pending task");
|
||||
return;
|
||||
}
|
||||
apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId });
|
||||
try {
|
||||
await api.cancelTaskById(pendingTaskId);
|
||||
} catch {
|
||||
apiLogger.info("cancelTask.success", { taskId: pendingTaskId });
|
||||
} catch (err) {
|
||||
// Task may already be completed
|
||||
apiLogger.warn("cancelTask.error (task may have already finished)", { taskId: pendingTaskId, err });
|
||||
}
|
||||
if (activeSessionId) {
|
||||
// Clear pending immediately; WS task:cancelled will confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
|
||||
}
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
}, [pendingTaskId, activeSessionId, clearTimeline, setPendingTask, qc]);
|
||||
}, [pendingTaskId, activeSessionId, qc]);
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
(agent: Agent) => {
|
||||
// No-op when clicking the already-active agent — don't clobber the
|
||||
// current session just because the user closed the menu this way.
|
||||
// Compare against activeAgent (what the UI shows), not selectedAgentId
|
||||
// (which may be null / point to an archived agent on first load).
|
||||
if (activeAgent && agent.id === activeAgent.id) return;
|
||||
uiLogger.info("selectAgent", {
|
||||
from: selectedAgentId,
|
||||
to: agent.id,
|
||||
previousSessionId: activeSessionId,
|
||||
});
|
||||
setSelectedAgentId(agent.id);
|
||||
// Reset session when switching agent
|
||||
setActiveSession(null);
|
||||
},
|
||||
[setSelectedAgentId, setActiveSession],
|
||||
[activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession],
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
uiLogger.info("newChat", {
|
||||
previousSessionId: activeSessionId,
|
||||
previousPendingTask: pendingTaskId,
|
||||
});
|
||||
setActiveSession(null);
|
||||
}, [activeSessionId, pendingTaskId, setActiveSession]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(session: ChatSession) => {
|
||||
// Sessions are bound 1:1 to an agent — picking a session from a
|
||||
// different agent implicitly switches the agent too.
|
||||
if (activeAgent && session.agent_id !== activeAgent.id) {
|
||||
uiLogger.info("selectSession (cross-agent)", {
|
||||
from: activeAgent.id,
|
||||
toAgent: session.agent_id,
|
||||
toSession: session.id,
|
||||
});
|
||||
setSelectedAgentId(session.agent_id);
|
||||
}
|
||||
setActiveSession(session.id);
|
||||
},
|
||||
[activeAgent, setSelectedAgentId, setActiveSession],
|
||||
);
|
||||
|
||||
const handleMinimize = useCallback(() => {
|
||||
uiLogger.info("minimize (close)", {
|
||||
activeSessionId,
|
||||
pendingTaskId,
|
||||
});
|
||||
setOpen(false);
|
||||
}, [activeSessionId, pendingTaskId, setOpen]);
|
||||
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
|
||||
|
||||
const hasMessages = messages.length > 0 || timelineItems.length > 0;
|
||||
// Show the list (vs empty state) as soon as there's anything to display —
|
||||
// a real message, or a pending task whose timeline will stream in.
|
||||
const hasMessages = messages.length > 0 || !!pendingTaskId;
|
||||
|
||||
const isVisible = isOpen && boundsReady;
|
||||
|
||||
@@ -248,115 +315,111 @@ export function ChatWindow() {
|
||||
return (
|
||||
<div ref={windowRef} className={containerClass} style={containerStyle}>
|
||||
<ChatResizeHandles onDragStart={startDrag} />
|
||||
{/* Header */}
|
||||
{!showHistory && (
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<AgentSelector
|
||||
{/* Header — ⊕ new + session dropdown | window tools */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5 gap-2">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={handleNewChat}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">New chat</TooltipContent>
|
||||
</Tooltip>
|
||||
<SessionDropdown
|
||||
sessions={sessions}
|
||||
// Use the full agent list (incl. archived) so historical
|
||||
// sessions can still resolve their avatar.
|
||||
agents={agents}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={toggleExpand}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isAtMax ? <Minimize2 /> : <Maximize2 />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{isAtMax ? "Restore" : "Expand"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleMinimize}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Minus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Minimize</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages / skeleton / empty state */}
|
||||
{showSkeleton ? (
|
||||
<ChatMessageSkeleton />
|
||||
) : hasMessages ? (
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
pendingTaskId={pendingTaskId}
|
||||
isWaiting={!!pendingTaskId}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
agentName={activeAgent?.name}
|
||||
onPickPrompt={(text) => handleSend(text)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input — disabled for archived sessions */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isRunning={!!pendingTaskId}
|
||||
disabled={isSessionArchived}
|
||||
agentName={activeAgent?.name}
|
||||
leftAdornment={
|
||||
<AgentDropdown
|
||||
agents={availableAgents}
|
||||
activeAgent={activeAgent}
|
||||
userId={user?.id}
|
||||
onSelect={handleSelectAgent}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setShowHistory(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<History />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Chat history</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
setActiveSession(null);
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={toggleExpand}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isAtMax ? <Minimize2 /> : <Maximize2 />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isAtMax ? "Restore" : "Expand"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Minus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Minimize</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHistory ? (
|
||||
<ChatSessionHistory />
|
||||
) : (
|
||||
<>
|
||||
{/* Messages or Empty State */}
|
||||
{hasMessages ? (
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
timelineItems={timelineItems}
|
||||
isWaiting={!!pendingTaskId}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState agentName={activeAgent?.name} />
|
||||
)}
|
||||
|
||||
{/* Input — disabled for archived sessions */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isRunning={!!pendingTaskId}
|
||||
disabled={isSessionArchived}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSelector({
|
||||
/**
|
||||
* Agent dropdown: avatar trigger, lists all available agents. Selecting a
|
||||
* different agent = switch agent + start a fresh chat (session=null).
|
||||
* The current agent is marked with a check and not clickable.
|
||||
*/
|
||||
function AgentDropdown({
|
||||
agents,
|
||||
activeAgent,
|
||||
userId,
|
||||
@@ -367,58 +430,54 @@ function AgentSelector({
|
||||
userId: string | undefined;
|
||||
onSelect: (agent: Agent) => void;
|
||||
}) {
|
||||
// Split into the user's own agents and everyone else so the menu groups
|
||||
// them — matches the old AgentSelector layout.
|
||||
const { mine, others } = useMemo(() => {
|
||||
const mine: Agent[] = [];
|
||||
const others: Agent[] = [];
|
||||
for (const a of agents) {
|
||||
if (a.owner_id === userId) mine.push(a);
|
||||
else others.push(a);
|
||||
}
|
||||
return { mine, others };
|
||||
}, [agents, userId]);
|
||||
|
||||
if (!activeAgent) {
|
||||
return <span className="text-sm text-muted-foreground">No agents</span>;
|
||||
return <span className="text-xs text-muted-foreground">No agents</span>;
|
||||
}
|
||||
|
||||
if (agents.length <= 1) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentAvatarSmall agent={activeAgent} />
|
||||
<span className="text-sm font-medium">{activeAgent.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const myAgents = agents.filter((a) => a.owner_id === userId);
|
||||
const othersAgents = agents.filter((a) => a.owner_id !== userId);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-1.5 py-1 -ml-1.5 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
<AgentAvatarSmall agent={activeAgent} />
|
||||
<span className="text-sm font-medium">{activeAgent.name}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-60 w-auto max-w-56">
|
||||
{myAgents.length > 0 && (
|
||||
<DropdownMenuContent align="start" side="top" className="max-h-80 w-auto max-w-64">
|
||||
{mine.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>My Agents</DropdownMenuLabel>
|
||||
{myAgents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLabel>My agents</DropdownMenuLabel>
|
||||
{mine.map((agent) => (
|
||||
<AgentMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</DropdownMenuItem>
|
||||
agent={agent}
|
||||
isCurrent={agent.id === activeAgent.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{myAgents.length > 0 && othersAgents.length > 0 && <DropdownMenuSeparator />}
|
||||
{othersAgents.length > 0 && (
|
||||
{mine.length > 0 && others.length > 0 && <DropdownMenuSeparator />}
|
||||
{others.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Others</DropdownMenuLabel>
|
||||
{othersAgents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
{others.map((agent) => (
|
||||
<AgentMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</DropdownMenuItem>
|
||||
agent={agent}
|
||||
isCurrent={agent.id === activeAgent.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
@@ -427,28 +486,142 @@ function AgentSelector({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMenuItem({
|
||||
agent,
|
||||
isCurrent,
|
||||
onSelect,
|
||||
}: {
|
||||
agent: Agent;
|
||||
isCurrent: boolean;
|
||||
onSelect: (agent: Agent) => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<span className="truncate flex-1">{agent.name}</span>
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session dropdown: lists ALL sessions across agents. Each row carries the
|
||||
* owning agent's avatar so the user can tell them apart. Selecting a
|
||||
* session from a different agent implicitly switches the agent too
|
||||
* (sessions are bound 1:1 to an agent). "New chat" lives in the header's
|
||||
* ⊕ button, not inside this dropdown.
|
||||
*/
|
||||
function SessionDropdown({
|
||||
sessions,
|
||||
agents,
|
||||
activeSessionId,
|
||||
onSelectSession,
|
||||
}: {
|
||||
sessions: ChatSession[];
|
||||
agents: Agent[];
|
||||
activeSessionId: string | null;
|
||||
onSelectSession: (session: ChatSession) => void;
|
||||
}) {
|
||||
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
||||
const title = activeSession?.title?.trim() || "New chat";
|
||||
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
{triggerAgent && <AgentAvatarSmall agent={triggerAgent} />}
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-80 w-auto min-w-56 max-w-80">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
No previous chats
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isCurrent = session.id === activeSessionId;
|
||||
const agent = agentById.get(session.agent_id) ?? null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
{agent ? (
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
) : (
|
||||
<span className="size-6 shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1 text-sm">
|
||||
{session.title?.trim() || "New chat"}
|
||||
</span>
|
||||
{session.has_unread && (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-brand" />
|
||||
)}
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentAvatarSmall({ agent }: { agent: Agent }) {
|
||||
return (
|
||||
<Avatar className="size-5">
|
||||
<Avatar className="size-6">
|
||||
{agent.avatar_url && <AvatarImage src={agent.avatar_url} />}
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700">
|
||||
<Bot className="size-3" />
|
||||
<Bot className="size-3.5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ agentName }: { agentName?: string }) {
|
||||
/**
|
||||
* Three starter prompts shown on the empty state. Tapping one sends it
|
||||
* immediately — ChatGPT-style — because the point is showing users what
|
||||
* this chat is for: operating on the workspace, not open-ended Q&A.
|
||||
*/
|
||||
const STARTER_PROMPTS: { icon: string; text: string }[] = [
|
||||
{ icon: "📋", text: "List my open tasks by priority" },
|
||||
{ icon: "📝", text: "Summarize what I did today" },
|
||||
{ icon: "💡", text: "Plan what to work on next" },
|
||||
];
|
||||
|
||||
function EmptyState({
|
||||
agentName,
|
||||
onPickPrompt,
|
||||
}: {
|
||||
agentName?: string;
|
||||
onPickPrompt: (text: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8">
|
||||
<Send className="size-8 text-muted-foreground/50" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-base font-semibold">Welcome to Multica</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{agentName
|
||||
? `Chat with ${agentName} or ask anything`
|
||||
: "Ask anything or tell Multica what you need"}
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
|
||||
<div className="text-center space-y-1">
|
||||
<h3 className="text-base font-semibold">
|
||||
{agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">Try asking</p>
|
||||
</div>
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
{STARTER_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt.text}
|
||||
type="button"
|
||||
onClick={() => onPickPrompt(prompt.text)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
|
||||
>
|
||||
<span className="mr-2">{prompt.icon}</span>
|
||||
{prompt.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,10 @@ interface ContentEditorProps {
|
||||
onSubmit?: () => void;
|
||||
onBlur?: () => void;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
/** Show the floating formatting toolbar on text selection. Defaults true. */
|
||||
showBubbleMenu?: boolean;
|
||||
/** When true, bare Enter submits (chat-style). Mod-Enter always submits. */
|
||||
submitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
interface ContentEditorRef {
|
||||
@@ -96,6 +100,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onUploadFile,
|
||||
showBubbleMenu = true,
|
||||
submitOnEnter = false,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -125,6 +131,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
queryClient,
|
||||
onSubmitRef,
|
||||
onUploadFileRef,
|
||||
submitOnEnter,
|
||||
}),
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
if (!onUpdateRef.current) return;
|
||||
@@ -240,7 +247,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onMouseDown={handleContainerMouseDown}
|
||||
>
|
||||
<EditorContent className="flex-1 min-h-full" editor={editor} />
|
||||
{editable && <EditorBubbleMenu editor={editor} />}
|
||||
{editable && showBubbleMenu && <EditorBubbleMenu editor={editor} />}
|
||||
<LinkHoverCard {...hover} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface EditorExtensionsOptions {
|
||||
onUploadFileRef?: RefObject<
|
||||
((file: File) => Promise<UploadResult | null>) | undefined
|
||||
>;
|
||||
/** When true, bare Enter also submits (chat-style). Default false. */
|
||||
submitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
export function createEditorExtensions(
|
||||
@@ -126,7 +128,15 @@ export function createEditorExtensions(
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: placeholderText }),
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(() => options.onSubmitRef?.current?.()),
|
||||
createSubmitExtension(
|
||||
() => {
|
||||
const fn = options.onSubmitRef?.current;
|
||||
if (!fn) return false; // no submit wired — let default Enter insert newline
|
||||
fn();
|
||||
return true;
|
||||
},
|
||||
{ submitOnEnter: options.submitOnEnter ?? false },
|
||||
),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export function createSubmitExtension(onSubmit: () => void) {
|
||||
/**
|
||||
* `onSubmit` must return true when it actually handled the event and false
|
||||
* when there's no submit handler wired up. That lets us fall through to the
|
||||
* default Enter behaviour — inserting a newline — when appropriate.
|
||||
*
|
||||
* `submitOnEnter` — when true, bare Enter also submits (chat-style). When
|
||||
* false, only Mod-Enter submits and bare Enter keeps its default (newline).
|
||||
*/
|
||||
export function createSubmitExtension(
|
||||
onSubmit: () => boolean,
|
||||
{ submitOnEnter }: { submitOnEnter: boolean },
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": () => {
|
||||
onSubmit();
|
||||
return true;
|
||||
},
|
||||
const shortcuts: Record<string, () => boolean> = {
|
||||
"Mod-Enter": () => onSubmit(),
|
||||
};
|
||||
if (submitOnEnter) {
|
||||
shortcuts.Enter = () => {
|
||||
const editor = this.editor;
|
||||
// IME guard — never submit while composing a multi-key input
|
||||
// (Chinese pinyin, Japanese kana, etc). `view.composing` is set
|
||||
// by ProseMirror between compositionstart and compositionend.
|
||||
if (editor.view.composing) return false;
|
||||
// Let Enter insert a newline inside a code block.
|
||||
if (editor.isActive("codeBlock")) return false;
|
||||
return onSubmit();
|
||||
};
|
||||
}
|
||||
return shortcuts;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -367,12 +367,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const recordVisit = useRecentIssuesStore((s) => s.recordVisit);
|
||||
useEffect(() => {
|
||||
if (issue) {
|
||||
recordVisit({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
});
|
||||
recordVisit(issue.id);
|
||||
}
|
||||
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
|
||||
@@ -83,6 +83,7 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
showCloseButton={false}
|
||||
className="inset-0 flex h-full w-full max-w-none sm:max-w-none translate-0 flex-col items-center justify-center rounded-none bg-background ring-0 shadow-none"
|
||||
>
|
||||
|
||||
@@ -59,6 +59,8 @@ function getOnboardingIssues(): OnboardingIssueDef[] {
|
||||
description: [
|
||||
"Skills are reusable instructions that make agents better at recurring tasks — deployments, code reviews, migrations, etc.",
|
||||
"",
|
||||
"**Note:** Skills already installed in your local runtime (e.g., `.claude/skills/`) are automatically available to agents — no need to re-upload them. Workspace skills here are for sharing knowledge across your team.",
|
||||
"",
|
||||
"**Steps:**",
|
||||
"1. Go to **Skills** in the sidebar",
|
||||
"2. Click **New Skill**",
|
||||
|
||||
@@ -5,10 +5,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SearchCommand } from "./search-command";
|
||||
import { useSearchStore } from "./search-store";
|
||||
|
||||
const { mockPush, mockSearchIssues, mockSearchProjects } = vi.hoisted(() => ({
|
||||
const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchIssues: vi.fn(),
|
||||
mockSearchProjects: vi.fn(),
|
||||
mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> },
|
||||
mockAllIssues: { current: [] as Array<Record<string, unknown>> },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
@@ -19,12 +21,24 @@ vi.mock("@multica/core/api", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores", () => ({
|
||||
useRecentIssuesStore: (selector?: (state: { items: [] }) => unknown) => {
|
||||
const state = { items: [] as [] };
|
||||
useRecentIssuesStore: (selector?: (state: { items: typeof mockRecentItems.current }) => unknown) => {
|
||||
const state = { items: mockRecentItems.current };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core", () => ({
|
||||
useWorkspaceId: () => "ws-test",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/queries", () => ({
|
||||
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: mockAllIssues.current }),
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: mockPush,
|
||||
@@ -36,6 +50,8 @@ describe("SearchCommand", () => {
|
||||
mockPush.mockReset();
|
||||
mockSearchIssues.mockReset().mockResolvedValue({ issues: [] });
|
||||
mockSearchProjects.mockReset().mockResolvedValue({ projects: [] });
|
||||
mockRecentItems.current = [];
|
||||
mockAllIssues.current = [];
|
||||
|
||||
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -97,4 +113,39 @@ describe("SearchCommand", () => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/settings");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("renders recent issues from query cache joined with store visit records", () => {
|
||||
mockRecentItems.current = [
|
||||
{ id: "issue-1", visitedAt: 1000 },
|
||||
{ id: "issue-2", visitedAt: 900 },
|
||||
];
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-1", title: "First issue", status: "todo" },
|
||||
{ id: "issue-2", identifier: "MUL-2", title: "Second issue", status: "done" },
|
||||
];
|
||||
|
||||
render(<SearchCommand />);
|
||||
|
||||
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||
expect(screen.getByText("First issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("MUL-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("MUL-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters out recent items not present in query cache", () => {
|
||||
mockRecentItems.current = [
|
||||
{ id: "issue-1", visitedAt: 1000 },
|
||||
{ id: "deleted-issue", visitedAt: 900 },
|
||||
];
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-1", title: "Existing issue", status: "in_progress" },
|
||||
];
|
||||
|
||||
render(<SearchCommand />);
|
||||
|
||||
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||
expect(screen.getByText("Existing issue")).toBeInTheDocument();
|
||||
expect(screen.queryByText("deleted-issue")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useWorkspaceId } from "@multica/core";
|
||||
import { StatusIcon } from "../issues/components";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
||||
@@ -97,7 +100,18 @@ export function SearchCommand() {
|
||||
const { push } = useNavigation();
|
||||
const open = useSearchStore((s) => s.open);
|
||||
const setOpen = useSearchStore((s) => s.setOpen);
|
||||
const recentIssues = useRecentIssuesStore((s) => s.items);
|
||||
const recentItems = useRecentIssuesStore((s) => s.items);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
|
||||
const recentIssues = useMemo(() => {
|
||||
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
|
||||
return recentItems.flatMap((item) => {
|
||||
const issue = issueMap.get(item.id);
|
||||
return issue ? [issue] : [];
|
||||
});
|
||||
}, [recentItems, allIssues]);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -96,9 +96,9 @@ function CreateSkillDialog({
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Skill</DialogTitle>
|
||||
<DialogTitle>Add Workspace Skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new skill or import from ClawHub / Skills.sh.
|
||||
Create a new skill or import from ClawHub / Skills.sh. Workspace skills are shared with your team and automatically injected into agent runs.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -744,9 +744,9 @@ export default function SkillsPage() {
|
||||
{skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No skills yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
Skills define reusable instructions for agents.
|
||||
<p className="mt-3 text-sm text-muted-foreground">No workspace skills yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center max-w-[280px]">
|
||||
Workspace skills are shared across your team and injected into agent runs. Skills already installed in your local runtime are used automatically.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
@@ -788,6 +788,9 @@ export default function SkillsPage() {
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="mt-3 text-sm">Select a skill to view details</p>
|
||||
<p className="mt-1 text-xs text-center max-w-[260px]">
|
||||
Workspace skills supplement your local skills and are shared across the team.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
|
||||
@@ -241,6 +241,20 @@ func TestCommentTriggerOnComment(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply to member thread after agent replied triggers agent", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
// Member starts a thread (top-level comment).
|
||||
threadID := postComment(t, issueID, "Please fix this bug", nil)
|
||||
clearTasks(t, issueID)
|
||||
// Agent replies in the thread.
|
||||
postCommentAsAgent(t, issueID, "Working on it, found the root cause.", agentID, strPtr(threadID))
|
||||
// Member follows up in the same thread without @mentioning the agent.
|
||||
postComment(t, issueID, "Great, please also check the edge case", strPtr(threadID))
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Errorf("expected 1 pending task (agent participated in thread), got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply to member thread mentioning assignee triggers agent", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
// Member starts a thread.
|
||||
|
||||
@@ -321,8 +321,11 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
r.Delete("/", h.ArchiveChatSession)
|
||||
r.Post("/messages", h.SendChatMessage)
|
||||
r.Get("/messages", h.ListChatMessages)
|
||||
r.Get("/pending-task", h.GetPendingChatTask)
|
||||
r.Post("/read", h.MarkChatSessionRead)
|
||||
})
|
||||
})
|
||||
r.Get("/api/chat/pending-tasks", h.ListPendingChatTasks)
|
||||
|
||||
// Inbox
|
||||
r.Route("/api/inbox", func(r chi.Router) {
|
||||
|
||||
@@ -73,27 +73,55 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
|
||||
var sessions []db.ChatSession
|
||||
var err error
|
||||
// Two call sites → two row types with identical shape. Collect into a
|
||||
// common response slice via small per-branch loops.
|
||||
var resp []ChatSessionResponse
|
||||
if status == "all" {
|
||||
sessions, err = h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
|
||||
rows, err := h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
CreatorID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
|
||||
return
|
||||
}
|
||||
resp = make([]ChatSessionResponse, len(rows))
|
||||
for i, s := range rows {
|
||||
resp[i] = ChatSessionResponse{
|
||||
ID: uuidToString(s.ID),
|
||||
WorkspaceID: uuidToString(s.WorkspaceID),
|
||||
AgentID: uuidToString(s.AgentID),
|
||||
CreatorID: uuidToString(s.CreatorID),
|
||||
Title: s.Title,
|
||||
Status: s.Status,
|
||||
HasUnread: s.HasUnread,
|
||||
CreatedAt: timestampToString(s.CreatedAt),
|
||||
UpdatedAt: timestampToString(s.UpdatedAt),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sessions, err = h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
|
||||
rows, err := h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
CreatorID: parseUUID(userID),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]ChatSessionResponse, len(sessions))
|
||||
for i, s := range sessions {
|
||||
resp[i] = chatSessionToResponse(s)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
|
||||
return
|
||||
}
|
||||
resp = make([]ChatSessionResponse, len(rows))
|
||||
for i, s := range rows {
|
||||
resp[i] = ChatSessionResponse{
|
||||
ID: uuidToString(s.ID),
|
||||
WorkspaceID: uuidToString(s.WorkspaceID),
|
||||
AgentID: uuidToString(s.AgentID),
|
||||
CreatorID: uuidToString(s.CreatorID),
|
||||
Title: s.Title,
|
||||
Status: s.Status,
|
||||
HasUnread: s.HasUnread,
|
||||
CreatedAt: timestampToString(s.CreatedAt),
|
||||
UpdatedAt: timestampToString(s.UpdatedAt),
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -273,6 +301,127 @@ func (h *Handler) ListChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// PendingChatTaskResponse is returned by GetPendingChatTask — either the
|
||||
// current in-flight task's id/status, or an empty object when none is active.
|
||||
type PendingChatTaskResponse struct {
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// MarkChatSessionRead clears the session's unread_since (→ has_unread=false)
|
||||
// and broadcasts chat:session_read so other devices of the same user drop
|
||||
// their badges.
|
||||
func (h *Handler) MarkChatSessionRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionId")
|
||||
|
||||
session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
|
||||
ID: parseUUID(sessionID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "chat session not found")
|
||||
return
|
||||
}
|
||||
if uuidToString(session.CreatorID) != userID {
|
||||
writeError(w, http.StatusForbidden, "not your chat session")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Queries.MarkChatSessionRead(r.Context(), parseUUID(sessionID)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark session read")
|
||||
return
|
||||
}
|
||||
|
||||
h.publish(protocol.EventChatSessionRead, workspaceID, "member", userID, protocol.ChatSessionReadPayload{
|
||||
ChatSessionID: sessionID,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// PendingChatTasksResponse is the aggregate view consumed by the FAB.
|
||||
type PendingChatTasksResponse struct {
|
||||
Tasks []PendingChatTaskItem `json:"tasks"`
|
||||
}
|
||||
|
||||
type PendingChatTaskItem struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
ChatSessionID string `json:"chat_session_id"`
|
||||
}
|
||||
|
||||
// ListPendingChatTasks returns every in-flight chat task owned by the current
|
||||
// user in this workspace. Drives the FAB's "running" indicator when the chat
|
||||
// window is closed (no per-session query is subscribed).
|
||||
func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
|
||||
rows, err := h.Queries.ListPendingChatTasksByCreator(r.Context(), db.ListPendingChatTasksByCreatorParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
CreatorID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list pending chat tasks")
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]PendingChatTaskItem, len(rows))
|
||||
for i, row := range rows {
|
||||
items[i] = PendingChatTaskItem{
|
||||
TaskID: uuidToString(row.TaskID),
|
||||
Status: row.Status,
|
||||
ChatSessionID: uuidToString(row.ChatSessionID),
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, PendingChatTasksResponse{Tasks: items})
|
||||
}
|
||||
|
||||
// GetPendingChatTask returns the most recent in-flight task (queued / dispatched
|
||||
// / running) for a chat session. The frontend polls this on mount / session
|
||||
// switch so pending UI state survives refresh and reopen.
|
||||
func (h *Handler) GetPendingChatTask(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionId")
|
||||
|
||||
session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
|
||||
ID: parseUUID(sessionID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "chat session not found")
|
||||
return
|
||||
}
|
||||
if uuidToString(session.CreatorID) != userID {
|
||||
writeError(w, http.StatusForbidden, "not your chat session")
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.Queries.GetPendingChatTask(r.Context(), parseUUID(sessionID))
|
||||
if err != nil {
|
||||
// No in-flight task — return an empty object, not an error.
|
||||
writeJSON(w, http.StatusOK, PendingChatTaskResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, PendingChatTaskResponse{
|
||||
TaskID: uuidToString(task.ID),
|
||||
Status: task.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task cancellation (user-facing, with ownership check)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -333,14 +482,16 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ChatSessionResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
CreatorID string `json:"creator_id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
CreatorID string `json:"creator_id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
// Only populated by list endpoints — single-session fetches return false.
|
||||
HasUnread bool `json:"has_unread"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ChatMessageResponse struct {
|
||||
|
||||
@@ -262,7 +262,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
// assignee — the user is continuing a member-to-member conversation.
|
||||
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
|
||||
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
|
||||
!h.isReplyToMemberThread(parentComment, comment.Content, issue) {
|
||||
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
|
||||
// Resolve thread root: if the comment is a reply, agent should reply
|
||||
// to the thread root (matching frontend behavior where all replies
|
||||
// in a thread share the same top-level parent).
|
||||
@@ -325,7 +325,9 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
|
||||
// in the reply, still triggers on_comment as expected.
|
||||
// If the parent (thread root) itself @mentions the assignee, the thread is
|
||||
// considered a conversation with the agent, so replies are allowed to trigger.
|
||||
func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issue db.Issue) bool {
|
||||
// If the assigned agent has already replied in the thread, the member is
|
||||
// conversing with the agent, so replies are allowed to trigger.
|
||||
func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment, content string, issue db.Issue) bool {
|
||||
if parent == nil {
|
||||
return false // Not a reply — normal top-level comment
|
||||
}
|
||||
@@ -333,7 +335,8 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
|
||||
return false // Thread started by an agent — allow trigger
|
||||
}
|
||||
// Thread was started by a member. Suppress on_comment unless the reply
|
||||
// or the parent explicitly @mentions the assignee agent.
|
||||
// or the parent explicitly @mentions the assignee agent, or the agent
|
||||
// has already participated in this thread.
|
||||
if !issue.AssigneeID.Valid {
|
||||
return true // No assignee to mention
|
||||
}
|
||||
@@ -351,7 +354,18 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
|
||||
return false // Assignee mentioned in thread root — allow trigger
|
||||
}
|
||||
}
|
||||
return true // Reply to member thread without mentioning agent — suppress
|
||||
// Check if the assigned agent has already replied in this thread —
|
||||
// if so, the member is continuing a conversation with the agent.
|
||||
if h.Queries != nil {
|
||||
hasReplied, err := h.Queries.HasAgentRepliedInThread(ctx, db.HasAgentRepliedInThreadParams{
|
||||
ParentID: parent.ID,
|
||||
AgentID: issue.AssigneeID,
|
||||
})
|
||||
if err == nil && hasReplied {
|
||||
return false // Agent participated in thread — allow trigger
|
||||
}
|
||||
}
|
||||
return true // Reply to member thread without agent participation — suppress
|
||||
}
|
||||
|
||||
// enqueueMentionedAgentTasks parses @agent mentions from comment content and
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
@@ -205,7 +206,7 @@ func TestIsReplyToMemberThread(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := h.isReplyToMemberThread(tt.parent, tt.content, issue)
|
||||
got := h.isReplyToMemberThread(context.Background(), tt.parent, tt.content, issue)
|
||||
if got != tt.want {
|
||||
t.Errorf("isReplyToMemberThread() = %v, want %v", got, tt.want)
|
||||
}
|
||||
@@ -233,7 +234,7 @@ func TestOnCommentTriggerDecision(t *testing.T) {
|
||||
// !commentMentionsOthersButNotAssignee && !isReplyToMemberThread
|
||||
shouldTrigger := func(parent *db.Comment, content string) bool {
|
||||
return !h.commentMentionsOthersButNotAssignee(content, issue) &&
|
||||
!h.isReplyToMemberThread(parent, content, issue)
|
||||
!h.isReplyToMemberThread(context.Background(), parent, content, issue)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -307,6 +307,14 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
|
||||
TaskID: task.ID,
|
||||
}); err != nil {
|
||||
slog.Error("failed to save assistant chat message", "task_id", util.UUIDToString(task.ID), "error", err)
|
||||
} else {
|
||||
// Event-driven unread: stamp unread_since on the first unread
|
||||
// assistant message. No-op if the session already has unread.
|
||||
// If the user is actively viewing the session, the frontend's
|
||||
// auto-mark-read effect will clear this within a tick.
|
||||
if err := s.Queries.SetUnreadSinceIfNull(ctx, task.ChatSessionID); err != nil {
|
||||
slog.Warn("failed to set unread_since", "chat_session_id", util.UUIDToString(task.ChatSessionID), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Queries.UpdateChatSessionSession(ctx, db.UpdateChatSessionSessionParams{
|
||||
|
||||
2
server/migrations/040_chat_unread_since.down.sql
Normal file
2
server/migrations/040_chat_unread_since.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_agent_task_queue_chat_pending;
|
||||
ALTER TABLE chat_session DROP COLUMN unread_since;
|
||||
16
server/migrations/040_chat_unread_since.up.sql
Normal file
16
server/migrations/040_chat_unread_since.up.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Event-driven unread tracking for chat sessions.
|
||||
--
|
||||
-- Semantics: unread_since is the timestamp of the first unread assistant
|
||||
-- message. It stays NULL while the session has no unread. It's SET when
|
||||
-- an assistant reply lands and the column was NULL. It's RESET to NULL
|
||||
-- when the user marks the session as read. Existing rows start as NULL,
|
||||
-- meaning "no unread to track" — historic chats are not mass-flagged.
|
||||
ALTER TABLE chat_session ADD COLUMN unread_since TIMESTAMPTZ;
|
||||
|
||||
-- GetPendingChatTask runs on every session open / switch and filters by
|
||||
-- chat_session_id + in-flight status + orders by created_at. A partial
|
||||
-- index on the in-flight subset keeps that query cheap as the queue grows.
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_task_queue_chat_pending
|
||||
ON agent_task_queue (chat_session_id, created_at DESC)
|
||||
WHERE chat_session_id IS NOT NULL
|
||||
AND status IN ('queued', 'dispatched', 'running');
|
||||
@@ -56,7 +56,7 @@ func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessagePa
|
||||
const createChatSession = `-- name: CreateChatSession :one
|
||||
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at
|
||||
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since
|
||||
`
|
||||
|
||||
type CreateChatSessionParams struct {
|
||||
@@ -85,6 +85,7 @@ func (q *Queries) CreateChatSession(ctx context.Context, arg CreateChatSessionPa
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -152,7 +153,7 @@ func (q *Queries) GetChatMessage(ctx context.Context, id pgtype.UUID) (ChatMessa
|
||||
}
|
||||
|
||||
const getChatSession = `-- name: GetChatSession :one
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since FROM chat_session
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -170,12 +171,13 @@ func (q *Queries) GetChatSession(ctx context.Context, id pgtype.UUID) (ChatSessi
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getChatSessionInWorkspace = `-- name: GetChatSessionInWorkspace :one
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since FROM chat_session
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
@@ -198,6 +200,7 @@ func (q *Queries) GetChatSessionInWorkspace(ctx context.Context, arg GetChatSess
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -221,10 +224,33 @@ func (q *Queries) GetLastChatTaskSession(ctx context.Context, chatSessionID pgty
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPendingChatTask = `-- name: GetPendingChatTask :one
|
||||
SELECT id, status FROM agent_task_queue
|
||||
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetPendingChatTaskRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Returns the most recent in-flight task for a chat session, if any.
|
||||
// Used by the frontend to recover pending state after refresh / reopen.
|
||||
func (q *Queries) GetPendingChatTask(ctx context.Context, chatSessionID pgtype.UUID) (GetPendingChatTaskRow, error) {
|
||||
row := q.db.QueryRow(ctx, getPendingChatTask, chatSessionID)
|
||||
var i GetPendingChatTaskRow
|
||||
err := row.Scan(&i.ID, &i.Status)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAllChatSessionsByCreator = `-- name: ListAllChatSessionsByCreator :many
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
|
||||
WHERE workspace_id = $1 AND creator_id = $2
|
||||
ORDER BY updated_at DESC
|
||||
SELECT cs.id, cs.workspace_id, cs.agent_id, cs.creator_id, cs.title, cs.session_id, cs.work_dir, cs.status, cs.created_at, cs.updated_at, cs.unread_since,
|
||||
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
||||
FROM chat_session cs
|
||||
WHERE cs.workspace_id = $1 AND cs.creator_id = $2
|
||||
ORDER BY cs.updated_at DESC
|
||||
`
|
||||
|
||||
type ListAllChatSessionsByCreatorParams struct {
|
||||
@@ -232,15 +258,30 @@ type ListAllChatSessionsByCreatorParams struct {
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllChatSessionsByCreatorParams) ([]ChatSession, error) {
|
||||
type ListAllChatSessionsByCreatorRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
Title string `json:"title"`
|
||||
SessionID pgtype.Text `json:"session_id"`
|
||||
WorkDir pgtype.Text `json:"work_dir"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
UnreadSince pgtype.Timestamptz `json:"unread_since"`
|
||||
HasUnread bool `json:"has_unread"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllChatSessionsByCreatorParams) ([]ListAllChatSessionsByCreatorRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAllChatSessionsByCreator, arg.WorkspaceID, arg.CreatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ChatSession{}
|
||||
items := []ListAllChatSessionsByCreatorRow{}
|
||||
for rows.Next() {
|
||||
var i ChatSession
|
||||
var i ListAllChatSessionsByCreatorRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
@@ -252,6 +293,8 @@ func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllC
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
&i.HasUnread,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -297,9 +340,11 @@ func (q *Queries) ListChatMessages(ctx context.Context, chatSessionID pgtype.UUI
|
||||
}
|
||||
|
||||
const listChatSessionsByCreator = `-- name: ListChatSessionsByCreator :many
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
|
||||
WHERE workspace_id = $1 AND creator_id = $2 AND status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
SELECT cs.id, cs.workspace_id, cs.agent_id, cs.creator_id, cs.title, cs.session_id, cs.work_dir, cs.status, cs.created_at, cs.updated_at, cs.unread_since,
|
||||
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
||||
FROM chat_session cs
|
||||
WHERE cs.workspace_id = $1 AND cs.creator_id = $2 AND cs.status = 'active'
|
||||
ORDER BY cs.updated_at DESC
|
||||
`
|
||||
|
||||
type ListChatSessionsByCreatorParams struct {
|
||||
@@ -307,15 +352,33 @@ type ListChatSessionsByCreatorParams struct {
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSessionsByCreatorParams) ([]ChatSession, error) {
|
||||
type ListChatSessionsByCreatorRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
Title string `json:"title"`
|
||||
SessionID pgtype.Text `json:"session_id"`
|
||||
WorkDir pgtype.Text `json:"work_dir"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
UnreadSince pgtype.Timestamptz `json:"unread_since"`
|
||||
HasUnread bool `json:"has_unread"`
|
||||
}
|
||||
|
||||
// Returns active sessions with a boolean unread flag. Unread is strictly
|
||||
// per-session: either the user has uncleared assistant replies in this
|
||||
// session or they don't. Counting messages would be misleading.
|
||||
func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSessionsByCreatorParams) ([]ListChatSessionsByCreatorRow, error) {
|
||||
rows, err := q.db.Query(ctx, listChatSessionsByCreator, arg.WorkspaceID, arg.CreatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ChatSession{}
|
||||
items := []ListChatSessionsByCreatorRow{}
|
||||
for rows.Next() {
|
||||
var i ChatSession
|
||||
var i ListChatSessionsByCreatorRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
@@ -327,6 +390,8 @@ func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSes
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
&i.HasUnread,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -338,6 +403,74 @@ func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSes
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPendingChatTasksByCreator = `-- name: ListPendingChatTasksByCreator :many
|
||||
SELECT atq.id AS task_id, atq.status, atq.chat_session_id
|
||||
FROM agent_task_queue atq
|
||||
JOIN chat_session cs ON cs.id = atq.chat_session_id
|
||||
WHERE cs.workspace_id = $1
|
||||
AND cs.creator_id = $2
|
||||
AND atq.status IN ('queued', 'dispatched', 'running')
|
||||
ORDER BY atq.created_at DESC
|
||||
`
|
||||
|
||||
type ListPendingChatTasksByCreatorParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
}
|
||||
|
||||
type ListPendingChatTasksByCreatorRow struct {
|
||||
TaskID pgtype.UUID `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
ChatSessionID pgtype.UUID `json:"chat_session_id"`
|
||||
}
|
||||
|
||||
// Aggregate view of all in-flight chat tasks owned by a given creator in a
|
||||
// workspace. Drives the FAB's "running" indicator when the chat window is
|
||||
// closed and no single session's query is active.
|
||||
func (q *Queries) ListPendingChatTasksByCreator(ctx context.Context, arg ListPendingChatTasksByCreatorParams) ([]ListPendingChatTasksByCreatorRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPendingChatTasksByCreator, arg.WorkspaceID, arg.CreatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListPendingChatTasksByCreatorRow{}
|
||||
for rows.Next() {
|
||||
var i ListPendingChatTasksByCreatorRow
|
||||
if err := rows.Scan(&i.TaskID, &i.Status, &i.ChatSessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markChatSessionRead = `-- name: MarkChatSessionRead :exec
|
||||
UPDATE chat_session SET unread_since = NULL
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
// Clears unread_since, dropping the session's unread count to 0.
|
||||
func (q *Queries) MarkChatSessionRead(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, markChatSessionRead, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const setUnreadSinceIfNull = `-- name: SetUnreadSinceIfNull :exec
|
||||
UPDATE chat_session SET unread_since = now()
|
||||
WHERE id = $1 AND unread_since IS NULL
|
||||
`
|
||||
|
||||
// Atomically stamps the first unread assistant message's arrival time.
|
||||
// No-op if the session is already in "has unread" state — keeps the earliest
|
||||
// unread boundary stable across multiple incoming replies.
|
||||
func (q *Queries) SetUnreadSinceIfNull(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, setUnreadSinceIfNull, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const touchChatSession = `-- name: TouchChatSession :exec
|
||||
UPDATE chat_session SET updated_at = now()
|
||||
WHERE id = $1
|
||||
@@ -367,7 +500,7 @@ func (q *Queries) UpdateChatSessionSession(ctx context.Context, arg UpdateChatSe
|
||||
const updateChatSessionTitle = `-- name: UpdateChatSessionTitle :one
|
||||
UPDATE chat_session SET title = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at
|
||||
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since
|
||||
`
|
||||
|
||||
type UpdateChatSessionTitleParams struct {
|
||||
@@ -389,6 +522,7 @@ func (q *Queries) UpdateChatSessionTitle(ctx context.Context, arg UpdateChatSess
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -153,6 +153,26 @@ func (q *Queries) HasAgentCommentedSince(ctx context.Context, arg HasAgentCommen
|
||||
return commented, err
|
||||
}
|
||||
|
||||
const hasAgentRepliedInThread = `-- name: HasAgentRepliedInThread :one
|
||||
SELECT count(*) > 0 AS has_replied FROM comment
|
||||
WHERE parent_id = $1 AND author_type = 'agent' AND author_id = $2
|
||||
`
|
||||
|
||||
type HasAgentRepliedInThreadParams struct {
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
}
|
||||
|
||||
// Returns true if the given agent has posted a reply in the thread rooted at
|
||||
// the specified parent comment. Used to detect agent participation in a
|
||||
// member-started thread so that follow-up member replies still trigger the agent.
|
||||
func (q *Queries) HasAgentRepliedInThread(ctx context.Context, arg HasAgentRepliedInThreadParams) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, hasAgentRepliedInThread, arg.ParentID, arg.AgentID)
|
||||
var has_replied bool
|
||||
err := row.Scan(&has_replied)
|
||||
return has_replied, err
|
||||
}
|
||||
|
||||
const listComments = `-- name: ListComments :many
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2
|
||||
|
||||
@@ -116,6 +116,7 @@ type ChatSession struct {
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
UnreadSince pgtype.Timestamptz `json:"unread_since"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
|
||||
@@ -12,14 +12,21 @@ SELECT * FROM chat_session
|
||||
WHERE id = $1 AND workspace_id = $2;
|
||||
|
||||
-- name: ListChatSessionsByCreator :many
|
||||
SELECT * FROM chat_session
|
||||
WHERE workspace_id = $1 AND creator_id = $2 AND status = 'active'
|
||||
ORDER BY updated_at DESC;
|
||||
-- Returns active sessions with a boolean unread flag. Unread is strictly
|
||||
-- per-session: either the user has uncleared assistant replies in this
|
||||
-- session or they don't. Counting messages would be misleading.
|
||||
SELECT cs.*,
|
||||
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
||||
FROM chat_session cs
|
||||
WHERE cs.workspace_id = $1 AND cs.creator_id = $2 AND cs.status = 'active'
|
||||
ORDER BY cs.updated_at DESC;
|
||||
|
||||
-- name: ListAllChatSessionsByCreator :many
|
||||
SELECT * FROM chat_session
|
||||
WHERE workspace_id = $1 AND creator_id = $2
|
||||
ORDER BY updated_at DESC;
|
||||
SELECT cs.*,
|
||||
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
||||
FROM chat_session cs
|
||||
WHERE cs.workspace_id = $1 AND cs.creator_id = $2
|
||||
ORDER BY cs.updated_at DESC;
|
||||
|
||||
-- name: UpdateChatSessionTitle :one
|
||||
UPDATE chat_session SET title = $2, updated_at = now()
|
||||
@@ -62,3 +69,35 @@ SELECT session_id, work_dir FROM agent_task_queue
|
||||
WHERE chat_session_id = $1 AND status = 'completed' AND session_id IS NOT NULL
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetPendingChatTask :one
|
||||
-- Returns the most recent in-flight task for a chat session, if any.
|
||||
-- Used by the frontend to recover pending state after refresh / reopen.
|
||||
SELECT id, status FROM agent_task_queue
|
||||
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListPendingChatTasksByCreator :many
|
||||
-- Aggregate view of all in-flight chat tasks owned by a given creator in a
|
||||
-- workspace. Drives the FAB's "running" indicator when the chat window is
|
||||
-- closed and no single session's query is active.
|
||||
SELECT atq.id AS task_id, atq.status, atq.chat_session_id
|
||||
FROM agent_task_queue atq
|
||||
JOIN chat_session cs ON cs.id = atq.chat_session_id
|
||||
WHERE cs.workspace_id = $1
|
||||
AND cs.creator_id = $2
|
||||
AND atq.status IN ('queued', 'dispatched', 'running')
|
||||
ORDER BY atq.created_at DESC;
|
||||
|
||||
-- name: MarkChatSessionRead :exec
|
||||
-- Clears unread_since, dropping the session's unread count to 0.
|
||||
UPDATE chat_session SET unread_since = NULL
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: SetUnreadSinceIfNull :exec
|
||||
-- Atomically stamps the first unread assistant message's arrival time.
|
||||
-- No-op if the session is already in "has unread" state — keeps the earliest
|
||||
-- unread boundary stable across multiple incoming replies.
|
||||
UPDATE chat_session SET unread_since = now()
|
||||
WHERE id = $1 AND unread_since IS NULL;
|
||||
|
||||
@@ -53,5 +53,12 @@ SELECT EXISTS (
|
||||
AND created_at >= @since
|
||||
) AS commented;
|
||||
|
||||
-- name: HasAgentRepliedInThread :one
|
||||
-- Returns true if the given agent has posted a reply in the thread rooted at
|
||||
-- the specified parent comment. Used to detect agent participation in a
|
||||
-- member-started thread so that follow-up member replies still trigger the agent.
|
||||
SELECT count(*) > 0 AS has_replied FROM comment
|
||||
WHERE parent_id = @parent_id AND author_type = 'agent' AND author_id = @agent_id;
|
||||
|
||||
-- name: DeleteComment :exec
|
||||
DELETE FROM comment WHERE id = $1;
|
||||
|
||||
@@ -59,8 +59,9 @@ const (
|
||||
EventSkillDeleted = "skill:deleted"
|
||||
|
||||
// Chat events
|
||||
EventChatMessage = "chat:message"
|
||||
EventChatDone = "chat:done"
|
||||
EventChatMessage = "chat:message"
|
||||
EventChatDone = "chat:done"
|
||||
EventChatSessionRead = "chat:session_read"
|
||||
|
||||
// Project events
|
||||
EventProjectCreated = "project:created"
|
||||
|
||||
@@ -74,6 +74,12 @@ type ChatDonePayload struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ChatSessionReadPayload is broadcast when the creator marks a session as read.
|
||||
// Fires to other devices so their unread counts stay in sync.
|
||||
type ChatSessionReadPayload struct {
|
||||
ChatSessionID string `json:"chat_session_id"`
|
||||
}
|
||||
|
||||
// HeartbeatPayload is sent periodically from daemon to server.
|
||||
type HeartbeatPayload struct {
|
||||
DaemonID string `json:"daemon_id"`
|
||||
|
||||
Reference in New Issue
Block a user