mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 18:39:17 +02:00
Compare commits
3 Commits
feature/co
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d905538f6 | ||
|
|
aece0c2864 | ||
|
|
6e3e7aad23 |
@@ -3,7 +3,7 @@ import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { chatKeys } from "./queries";
|
||||
import { createLogger } from "../logger";
|
||||
import type { ChatSession } from "../types";
|
||||
import type { ChatMessage, ChatSession } from "../types";
|
||||
|
||||
const logger = createLogger("chat.mut");
|
||||
|
||||
@@ -28,6 +28,58 @@ export function useCreateChatSession() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a user message to an existing chat session. Optimistically appends
|
||||
* the message to the cached thread so it shows instantly, then invalidates
|
||||
* the thread, the session's pending-task signal, and the sessions list (the
|
||||
* reply bumps updated_at / re-orders the inbox feed) once the request
|
||||
* settles. Rolls the optimistic message back on failure.
|
||||
*
|
||||
* Scoped to an existing session id — the floating chat's lazy
|
||||
* session-creation path is separate; this is the inline (inbox) surface.
|
||||
*/
|
||||
export function useSendChatMessage(sessionId: string) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { content: string; attachmentIds?: string[] }) => {
|
||||
logger.info("sendChatMessage.start", {
|
||||
sessionId,
|
||||
contentLength: vars.content.length,
|
||||
attachmentCount: vars.attachmentIds?.length ?? 0,
|
||||
});
|
||||
return api.sendChatMessage(sessionId, vars.content, vars.attachmentIds);
|
||||
},
|
||||
onMutate: async (vars) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
const prev = qc.getQueryData<ChatMessage[]>(chatKeys.messages(sessionId));
|
||||
const optimistic: ChatMessage = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
chat_session_id: sessionId,
|
||||
role: "user",
|
||||
content: vars.content,
|
||||
task_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
attachments: [],
|
||||
};
|
||||
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
|
||||
old ? [...old, optimistic] : [optimistic],
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (err, _vars, ctx) => {
|
||||
logger.error("sendChatMessage.error.rollback", err);
|
||||
if (ctx?.prev) qc.setQueryData(chatKeys.messages(sessionId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the session's unread state server-side. Optimistically flips
|
||||
* has_unread to false in the cached list so the FAB badge drops
|
||||
|
||||
@@ -5,13 +5,12 @@ import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import {
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
useInboxUnreadCount,
|
||||
} from "@multica/core/inbox/queries";
|
||||
import { chatSessionsOptions } from "@multica/core/chat/queries";
|
||||
import {
|
||||
useMarkInboxRead,
|
||||
useArchiveInbox,
|
||||
@@ -51,29 +50,65 @@ import {
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { InboxListItem, useTimeAgo } from "./inbox-list-item";
|
||||
import { useTypeLabels } from "./inbox-detail-label";
|
||||
import { getInboxDisplayTitle } from "./inbox-display";
|
||||
import {
|
||||
getInboxItemRenderer,
|
||||
notificationEntry,
|
||||
conversationEntry,
|
||||
type InboxFeedEntry,
|
||||
} from "../item-types";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
export function InboxPage() {
|
||||
const { t } = useT("inbox");
|
||||
const { searchParams, replace } = useNavigation();
|
||||
const urlIssue = searchParams.get("issue") ?? "";
|
||||
const urlConversation = searchParams.get("conversation") ?? "";
|
||||
const wsPaths = useWorkspacePaths();
|
||||
|
||||
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
|
||||
const [selectedConversationId, setSelectedConversationId] = useState(
|
||||
() => urlConversation,
|
||||
);
|
||||
|
||||
// Sync from URL when searchParams change (e.g. navigation)
|
||||
useEffect(() => {
|
||||
setSelectedKeyState(urlIssue);
|
||||
}, [urlIssue]);
|
||||
useEffect(() => {
|
||||
setSelectedConversationId(urlConversation);
|
||||
}, [urlConversation]);
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
|
||||
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
|
||||
|
||||
// Agent conversations are surfaced inline in the same feed as notifications.
|
||||
const { data: rawSessions = [] } = useQuery(chatSessionsOptions(wsId));
|
||||
const sessions = useMemo(
|
||||
() => rawSessions.filter((s) => s.status === "active"),
|
||||
[rawSessions],
|
||||
);
|
||||
|
||||
// The merged typed feed: notifications + conversations, newest first. Each
|
||||
// entry dispatches to its item-type renderer for the row.
|
||||
const feed = useMemo<InboxFeedEntry[]>(() => {
|
||||
const entries = [
|
||||
...items.map(notificationEntry),
|
||||
...sessions.map(conversationEntry),
|
||||
];
|
||||
return entries.sort((a, b) => b.sortAt.localeCompare(a.sortAt));
|
||||
}, [items, sessions]);
|
||||
|
||||
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
|
||||
const selectedConversation =
|
||||
sessions.find((s) => s.id === selectedConversationId) ?? null;
|
||||
|
||||
// Notifications backed by an issue defer to the shared IssueDetail surface;
|
||||
// everything else renders the detail pane its item-type renderer provides.
|
||||
const SelectedDetail =
|
||||
selected && !selected.issue_id
|
||||
? getInboxItemRenderer("issue_notification").Detail
|
||||
: undefined;
|
||||
|
||||
// Track the last key we actually resolved against the inbox list. Lets the
|
||||
// fallback effect distinguish "shared-link to a notification not in our
|
||||
@@ -86,11 +121,21 @@ export function InboxPage() {
|
||||
|
||||
const setSelectedKey = useCallback((key: string) => {
|
||||
setSelectedKeyState(key);
|
||||
setSelectedConversationId("");
|
||||
const inboxPath = wsPaths.inbox();
|
||||
const url = key ? `${inboxPath}?issue=${key}` : inboxPath;
|
||||
replace(url);
|
||||
}, [replace, wsPaths]);
|
||||
|
||||
// Open a conversation in the detail pane (like opening an issue), clearing
|
||||
// any notification selection. The floating chat window is intentionally not
|
||||
// involved — it is being deprecated in favour of this inline surface.
|
||||
const selectConversation = useCallback((id: string) => {
|
||||
setSelectedConversationId(id);
|
||||
setSelectedKeyState("");
|
||||
replace(`${wsPaths.inbox()}?conversation=${id}`);
|
||||
}, [replace, wsPaths]);
|
||||
|
||||
// Shared inbox links (?issue=<id>) may point to notifications not in this
|
||||
// user's inbox (archived, or never received). Fall back to the issue page
|
||||
// so the URL still resolves to something meaningful. But if the key was
|
||||
@@ -121,8 +166,6 @@ export function InboxPage() {
|
||||
const archiveAllMutation = useArchiveAllInbox();
|
||||
const archiveAllReadMutation = useArchiveAllReadInbox();
|
||||
const archiveCompletedMutation = useArchiveCompletedInbox();
|
||||
const timeAgo = useTimeAgo();
|
||||
const typeLabels = useTypeLabels();
|
||||
|
||||
|
||||
// Auto-mark-read whenever a selected item is unread — covers both click-
|
||||
@@ -267,26 +310,46 @@ export function InboxPage() {
|
||||
</PageHeader>
|
||||
);
|
||||
|
||||
const listBody = items.length === 0 ? (
|
||||
const listBody = feed.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm">{t(($) => $.list.empty)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<InboxListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={(item.issue_id ?? item.id) === selectedKey}
|
||||
onClick={() => handleSelect(item)}
|
||||
onArchive={() => handleArchive(item.id)}
|
||||
/>
|
||||
))}
|
||||
{feed.map((entry) => {
|
||||
const { Row } = getInboxItemRenderer(entry.kind);
|
||||
const isNotification = entry.kind === "issue_notification";
|
||||
return (
|
||||
<Row
|
||||
key={`${entry.kind}:${entry.id}`}
|
||||
entry={entry}
|
||||
isSelected={
|
||||
isNotification
|
||||
? (entry.notification.issue_id ?? entry.notification.id) === selectedKey
|
||||
: entry.id === selectedConversationId
|
||||
}
|
||||
onSelect={() => {
|
||||
// Both kinds open into the same detail pane.
|
||||
if (isNotification) handleSelect(entry.notification);
|
||||
else selectConversation(entry.id);
|
||||
}}
|
||||
onArchive={() => {
|
||||
if (isNotification) handleArchive(entry.notification.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const detailContent = selected?.issue_id ? (
|
||||
const ConversationDetailComp = getInboxItemRenderer("conversation").Detail;
|
||||
const detailContent = selectedConversation && ConversationDetailComp ? (
|
||||
<ConversationDetailComp
|
||||
entry={conversationEntry(selectedConversation)}
|
||||
onArchive={() => {}}
|
||||
/>
|
||||
) : selected?.issue_id ? (
|
||||
// Key by issue_id (not inbox-item id): a new comment/reaction generates a
|
||||
// new inbox notification for the same issue, and the dedup helper picks the
|
||||
// newest one — keying on its id would remount IssueDetail on every event,
|
||||
@@ -310,58 +373,11 @@ export function InboxPage() {
|
||||
}}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
) : selected ? (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold">{getInboxDisplayTitle(selected)}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
|
||||
</p>
|
||||
{selected.body && (
|
||||
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
||||
{selected.body}
|
||||
</div>
|
||||
)}
|
||||
{selected.type === "quick_create_failed" && selected.details?.original_prompt && (
|
||||
<div className="mt-4 rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.detail.original_input)}
|
||||
</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm">{selected.details.original_prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
{selected.type === "quick_create_failed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Seed the legacy advanced form with the original prompt so the
|
||||
// user can recover their input in the full editor instead of
|
||||
// retyping. The agent picker hint becomes the assignee
|
||||
// candidate (still editable).
|
||||
const prompt = selected.details?.original_prompt ?? "";
|
||||
const agentId = selected.details?.agent_id;
|
||||
useIssueDraftStore.getState().setDraft({
|
||||
description: prompt,
|
||||
...(agentId
|
||||
? { assigneeType: "agent" as const, assigneeId: agentId }
|
||||
: {}),
|
||||
});
|
||||
useModalStore.getState().open("create-issue");
|
||||
}}
|
||||
>
|
||||
{t(($) => $.detail.edit_advanced)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleArchive(selected.id)}
|
||||
>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t(($) => $.detail.archive)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : selected && SelectedDetail ? (
|
||||
<SelectedDetail
|
||||
entry={notificationEntry(selected)}
|
||||
onArchive={() => handleArchive(selected.id)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// -- Mobile layout: list / detail toggle -----------------------------------
|
||||
@@ -389,7 +405,7 @@ export function InboxPage() {
|
||||
}
|
||||
|
||||
// Mobile: show detail full-screen when an item is selected
|
||||
if (selected) {
|
||||
if (selected || selectedConversation) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<div className="flex h-12 shrink-0 items-center border-b px-2">
|
||||
@@ -472,7 +488,7 @@ export function InboxPage() {
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Inbox className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm">
|
||||
{items.length === 0
|
||||
{feed.length === 0
|
||||
? t(($) => $.detail.empty)
|
||||
: t(($) => $.detail.select_prompt)}
|
||||
</p>
|
||||
|
||||
79
packages/views/inbox/item-types/contract.test.ts
Normal file
79
packages/views/inbox/item-types/contract.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatSession, InboxItem } from "@multica/core/types";
|
||||
import {
|
||||
conversationEntry,
|
||||
getInboxItemRenderer,
|
||||
hasInboxItemRenderer,
|
||||
notificationEntry,
|
||||
} from "./index";
|
||||
|
||||
function item(overrides: Partial<InboxItem> = {}): InboxItem {
|
||||
return {
|
||||
id: "inbox-1",
|
||||
workspace_id: "workspace-1",
|
||||
recipient_type: "member",
|
||||
recipient_id: "member-1",
|
||||
actor_type: "agent",
|
||||
actor_id: "agent-1",
|
||||
type: "new_comment",
|
||||
severity: "info",
|
||||
issue_id: "issue-1",
|
||||
title: "Issue title",
|
||||
body: null,
|
||||
issue_status: null,
|
||||
read: false,
|
||||
archived: false,
|
||||
created_at: "2026-04-29T12:00:00Z",
|
||||
details: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function session(overrides: Partial<ChatSession> = {}): ChatSession {
|
||||
return {
|
||||
id: "session-1",
|
||||
workspace_id: "workspace-1",
|
||||
agent_id: "agent-1",
|
||||
creator_id: "member-1",
|
||||
title: "Fix login redirect",
|
||||
status: "active",
|
||||
has_unread: true,
|
||||
created_at: "2026-04-29T11:00:00Z",
|
||||
updated_at: "2026-04-29T13:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("inbox feed entries", () => {
|
||||
it("projects a notification onto an issue_notification entry", () => {
|
||||
const entry = notificationEntry(item({ id: "n1", read: false }));
|
||||
expect(entry.kind).toBe("issue_notification");
|
||||
expect(entry.id).toBe("n1");
|
||||
expect(entry.unread).toBe(true);
|
||||
expect(entry.sortAt).toBe("2026-04-29T12:00:00Z");
|
||||
});
|
||||
|
||||
it("projects a chat session onto a conversation entry sorted by updated_at", () => {
|
||||
const entry = conversationEntry(session({ id: "s1", has_unread: false }));
|
||||
expect(entry.kind).toBe("conversation");
|
||||
expect(entry.id).toBe("s1");
|
||||
expect(entry.unread).toBe(false);
|
||||
expect(entry.sortAt).toBe("2026-04-29T13:00:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inbox item-type registry", () => {
|
||||
it("registers renderers for both shipping kinds", () => {
|
||||
expect(hasInboxItemRenderer("issue_notification")).toBe(true);
|
||||
expect(hasInboxItemRenderer("conversation")).toBe(true);
|
||||
|
||||
const notif = getInboxItemRenderer("issue_notification");
|
||||
expect(typeof notif.Row).toBe("function");
|
||||
expect(typeof notif.Detail).toBe("function");
|
||||
|
||||
const convo = getInboxItemRenderer("conversation");
|
||||
expect(typeof convo.Row).toBe("function");
|
||||
// Conversations open inline in the detail pane (like an issue).
|
||||
expect(typeof convo.Detail).toBe("function");
|
||||
});
|
||||
});
|
||||
113
packages/views/inbox/item-types/contract.ts
Normal file
113
packages/views/inbox/item-types/contract.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ChatSession, InboxItem } from "@multica/core/types";
|
||||
|
||||
/**
|
||||
* Inbox is a *typed feed*: one list whose entries each declare a high-level
|
||||
* `kind`, and a registered renderer decides how that kind looks in the list
|
||||
* and behaves when opened. The envelope (id, sort time, unread) and the
|
||||
* list/triage machinery are shared across kinds; only the row + detail are
|
||||
* polymorphic.
|
||||
*
|
||||
* Two kinds ship today, both backed by real data:
|
||||
* - `issue_notification` — an {@link InboxItem} from the inbox feed.
|
||||
* - `conversation` — an agent {@link ChatSession} surfaced inline.
|
||||
*
|
||||
* Further kinds (approval, digest, …) register another renderer without
|
||||
* touching the list, filtering, sorting, or read machinery. See MUL-3788.
|
||||
*/
|
||||
export type InboxItemKind = "issue_notification" | "conversation";
|
||||
|
||||
/**
|
||||
* A normalized entry in the merged feed. Sources as different as a
|
||||
* notification and a chat session are projected onto one shape — `id`,
|
||||
* `sortAt`, `unread` form the shared envelope the list sorts/filters on —
|
||||
* while the kind-specific payload rides along for its renderer.
|
||||
*/
|
||||
export type InboxFeedEntry =
|
||||
| {
|
||||
kind: "issue_notification";
|
||||
id: string;
|
||||
sortAt: string;
|
||||
unread: boolean;
|
||||
notification: InboxItem;
|
||||
}
|
||||
| {
|
||||
kind: "conversation";
|
||||
id: string;
|
||||
sortAt: string;
|
||||
unread: boolean;
|
||||
conversation: ChatSession;
|
||||
};
|
||||
|
||||
/** Project a raw notification onto a feed entry. */
|
||||
export function notificationEntry(item: InboxItem): InboxFeedEntry {
|
||||
return {
|
||||
kind: "issue_notification",
|
||||
id: item.id,
|
||||
sortAt: item.created_at,
|
||||
unread: !item.read,
|
||||
notification: item,
|
||||
};
|
||||
}
|
||||
|
||||
/** Project a chat session onto a feed entry. */
|
||||
export function conversationEntry(session: ChatSession): InboxFeedEntry {
|
||||
return {
|
||||
kind: "conversation",
|
||||
id: session.id,
|
||||
sortAt: session.updated_at,
|
||||
unread: session.has_unread,
|
||||
conversation: session,
|
||||
};
|
||||
}
|
||||
|
||||
/** Props every kind's list-row component receives. */
|
||||
export interface InboxItemRowProps {
|
||||
entry: InboxFeedEntry;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onArchive: () => void;
|
||||
}
|
||||
|
||||
/** Props every kind's detail-pane component receives. */
|
||||
export interface InboxItemDetailProps {
|
||||
entry: InboxFeedEntry;
|
||||
onArchive: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A renderer is the per-kind half of the contract. `Row` is required (a kind
|
||||
* must be listable); `Detail` is optional — a kind may open elsewhere (e.g. a
|
||||
* conversation opens the chat window) or defer to a shared surface (an
|
||||
* issue-backed notification defers to `IssueDetail`) instead of rendering its
|
||||
* own pane.
|
||||
*/
|
||||
export interface InboxItemRenderer {
|
||||
kind: InboxItemKind;
|
||||
Row: ComponentType<InboxItemRowProps>;
|
||||
Detail?: ComponentType<InboxItemDetailProps>;
|
||||
}
|
||||
|
||||
const registry = new Map<InboxItemKind, InboxItemRenderer>();
|
||||
|
||||
/** Register a renderer for an item kind. Last registration wins. */
|
||||
export function registerInboxItemType(renderer: InboxItemRenderer): void {
|
||||
registry.set(renderer.kind, renderer);
|
||||
}
|
||||
|
||||
export function hasInboxItemRenderer(kind: InboxItemKind): boolean {
|
||||
return registry.has(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the renderer for a kind. Throws if none is registered — a missing
|
||||
* renderer is a wiring bug (the registering module was not imported), not a
|
||||
* runtime condition to handle.
|
||||
*/
|
||||
export function getInboxItemRenderer(kind: InboxItemKind): InboxItemRenderer {
|
||||
const renderer = registry.get(kind);
|
||||
if (!renderer) {
|
||||
throw new Error(`No inbox item renderer registered for kind "${kind}"`);
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
141
packages/views/inbox/item-types/conversation.tsx
Normal file
141
packages/views/inbox/item-types/conversation.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import {
|
||||
chatMessagesOptions,
|
||||
pendingChatTaskOptions,
|
||||
} from "@multica/core/chat/queries";
|
||||
import {
|
||||
useSendChatMessage,
|
||||
useMarkChatSessionRead,
|
||||
} from "@multica/core/chat/mutations";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { ChatMessageList } from "../../chat/components/chat-message-list";
|
||||
import { ChatInput } from "../../chat/components/chat-input";
|
||||
import { useTimeAgo } from "../components/inbox-list-item";
|
||||
import {
|
||||
registerInboxItemType,
|
||||
type InboxItemDetailProps,
|
||||
type InboxItemRowProps,
|
||||
} from "./contract";
|
||||
|
||||
/**
|
||||
* Renderer for the `conversation` kind — an agent chat session surfaced inline
|
||||
* in the inbox feed. Selecting it opens the conversation in the inbox detail
|
||||
* pane (the same surface an issue notification opens into), NOT the floating
|
||||
* chat window. The detail pane reuses the chat thread + composer so it stays
|
||||
* in sync with the chat surface that will eventually replace the floating one.
|
||||
*/
|
||||
function ConversationRow({ entry, isSelected, onSelect }: InboxItemRowProps) {
|
||||
const { getActorName } = useActorName();
|
||||
const timeAgo = useTimeAgo();
|
||||
|
||||
if (entry.kind !== "conversation") return null;
|
||||
const session = entry.conversation;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`group flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={session.agent_id} size={28} enableHoverCard />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{entry.unread && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate text-sm ${entry.unread ? "font-medium" : "text-muted-foreground"}`}
|
||||
>
|
||||
{session.title}
|
||||
</span>
|
||||
</div>
|
||||
<MessageCircle className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<p className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs text-muted-foreground">
|
||||
{getActorName("agent", session.agent_id)}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{timeAgo(session.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationDetail({ entry }: InboxItemDetailProps) {
|
||||
const session = entry.kind === "conversation" ? entry.conversation : null;
|
||||
const sessionId = session?.id ?? "";
|
||||
const agentId = session?.agent_id ?? "";
|
||||
|
||||
const { getActorName } = useActorName();
|
||||
const { data: messages = [] } = useQuery(chatMessagesOptions(sessionId));
|
||||
const { data: pendingTask } = useQuery(pendingChatTaskOptions(sessionId));
|
||||
const sendMessage = useSendChatMessage(sessionId);
|
||||
const markRead = useMarkChatSessionRead();
|
||||
|
||||
// Mark read when the conversation is opened in the pane (mirrors the inbox
|
||||
// notification auto-mark-read). Optimistic, so it settles in one pass.
|
||||
const markReadMutate = markRead.mutate;
|
||||
const isUnread = entry.kind === "conversation" && entry.unread;
|
||||
useEffect(() => {
|
||||
if (!sessionId || !isUnread) return;
|
||||
markReadMutate(sessionId);
|
||||
}, [sessionId, isUnread, markReadMutate]);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2.5 border-b px-4">
|
||||
<ActorAvatar actorType="agent" actorId={agentId} size={24} enableHoverCard />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{session.title}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{getActorName("agent", agentId)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ChatMessageList
|
||||
key={sessionId}
|
||||
messages={messages}
|
||||
pendingTask={pendingTask}
|
||||
availability={undefined}
|
||||
/>
|
||||
</div>
|
||||
<ChatInput
|
||||
onSend={async (content, attachmentIds, commitInput) => {
|
||||
const text = content.trim();
|
||||
if (!text) return false;
|
||||
commitInput?.({ clearEditor: true });
|
||||
try {
|
||||
await sendMessage.mutateAsync({ content: text, attachmentIds });
|
||||
return true;
|
||||
} catch {
|
||||
toast.error("Failed to send message");
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
isRunning={!!pendingTask?.task_id}
|
||||
agentName={getActorName("agent", agentId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerInboxItemType({
|
||||
kind: "conversation",
|
||||
Row: ConversationRow,
|
||||
Detail: ConversationDetail,
|
||||
});
|
||||
18
packages/views/inbox/item-types/index.ts
Normal file
18
packages/views/inbox/item-types/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Importing this module registers the built-in inbox item-type renderers as a
|
||||
// side effect. Import it once (the inbox page does) before resolving a renderer
|
||||
// via getInboxItemRenderer.
|
||||
import "./issue-notification";
|
||||
import "./conversation";
|
||||
|
||||
export {
|
||||
conversationEntry,
|
||||
getInboxItemRenderer,
|
||||
hasInboxItemRenderer,
|
||||
notificationEntry,
|
||||
registerInboxItemType,
|
||||
type InboxFeedEntry,
|
||||
type InboxItemDetailProps,
|
||||
type InboxItemKind,
|
||||
type InboxItemRenderer,
|
||||
type InboxItemRowProps,
|
||||
} from "./contract";
|
||||
105
packages/views/inbox/item-types/issue-notification.tsx
Normal file
105
packages/views/inbox/item-types/issue-notification.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Archive } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import { useT } from "../../i18n";
|
||||
import { InboxListItem, useTimeAgo } from "../components/inbox-list-item";
|
||||
import { useTypeLabels } from "../components/inbox-detail-label";
|
||||
import { getInboxDisplayTitle } from "../components/inbox-display";
|
||||
import {
|
||||
registerInboxItemType,
|
||||
type InboxItemDetailProps,
|
||||
type InboxItemRowProps,
|
||||
} from "./contract";
|
||||
|
||||
/**
|
||||
* Renderer for the `issue_notification` kind — every notification the server
|
||||
* issues today. The list row reuses {@link InboxListItem}; the detail pane
|
||||
* below is only used for notifications that have NO backing issue (e.g. a
|
||||
* failed quick-create). Notifications that point at an issue defer to the
|
||||
* shared `IssueDetail` surface, so this `Detail` deliberately omits that path.
|
||||
*/
|
||||
function IssueNotificationRow({
|
||||
entry,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onArchive,
|
||||
}: InboxItemRowProps) {
|
||||
if (entry.kind !== "issue_notification") return null;
|
||||
return (
|
||||
<InboxListItem
|
||||
item={entry.notification}
|
||||
isSelected={isSelected}
|
||||
onClick={onSelect}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueNotificationDetail({ entry, onArchive }: InboxItemDetailProps) {
|
||||
const { t } = useT("inbox");
|
||||
const typeLabels = useTypeLabels();
|
||||
const timeAgo = useTimeAgo();
|
||||
if (entry.kind !== "issue_notification") return null;
|
||||
const item = entry.notification;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold">{getInboxDisplayTitle(item)}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{typeLabels[item.type]} · {timeAgo(item.created_at)}
|
||||
</p>
|
||||
{item.body && (
|
||||
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
||||
{item.body}
|
||||
</div>
|
||||
)}
|
||||
{item.type === "quick_create_failed" && item.details?.original_prompt && (
|
||||
<div className="mt-4 rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.detail.original_input)}
|
||||
</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm">
|
||||
{item.details.original_prompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
{item.type === "quick_create_failed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Seed the legacy advanced form with the original prompt so the
|
||||
// user can recover their input in the full editor instead of
|
||||
// retyping. The agent picker hint becomes the assignee
|
||||
// candidate (still editable).
|
||||
const prompt = item.details?.original_prompt ?? "";
|
||||
const agentId = item.details?.agent_id;
|
||||
useIssueDraftStore.getState().setDraft({
|
||||
description: prompt,
|
||||
...(agentId
|
||||
? { assigneeType: "agent" as const, assigneeId: agentId }
|
||||
: {}),
|
||||
});
|
||||
useModalStore.getState().open("create-issue");
|
||||
}}
|
||||
>
|
||||
{t(($) => $.detail.edit_advanced)}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onArchive}>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t(($) => $.detail.archive)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerInboxItemType({
|
||||
kind: "issue_notification",
|
||||
Row: IssueNotificationRow,
|
||||
Detail: IssueNotificationDetail,
|
||||
});
|
||||
Reference in New Issue
Block a user