Compare commits

...

3 Commits

Author SHA1 Message Date
Jiayuan
2d905538f6 feat(inbox): open conversations in the detail pane, not the floating chat
Selecting a conversation in the inbox now opens it inline in the right
detail pane (the same surface a notification's issue opens into), instead
of popping the floating bottom-right chat window — which is being
deprecated.

- conversation renderer gains an inline Detail: header + the chat thread
  (reuses ChatMessageList) + a composer (reuses ChatInput). Marks the
  session read on open.
- conversation Row now selects into the pane (no setActiveSession/setOpen);
  the floating chat is no longer involved.
- inbox-page tracks conversation selection (URL ?conversation=<id>),
  dispatches the detail pane to the conversation renderer, and keeps the
  notification selection / IssueDetail path untouched. Mobile shows the
  conversation full-screen like a notification.
- add useSendChatMessage (core): optimistic append + send to an existing
  session, invalidating thread / pending-task / sessions on settle.

Refs MUL-3788

Co-authored-by: multica-agent <github@multica.ai>
2026-06-28 12:18:58 +08:00
Jiayuan
aece0c2864 feat(inbox): merge agent conversations into the typed inbox feed
Realizes the merged typed feed from MUL-3788: the inbox now lists agent
conversations (existing chat sessions) inline with issue notifications, in
one recency-sorted list, each dispatched to its item-type renderer.

- generalize the registry to a normalized InboxFeedEntry union
  (issue_notification | conversation) with a shared envelope (id, sortAt,
  unread); notificationEntry() / conversationEntry() project each source
- add the conversation renderer: surfaces an agent ChatSession as a row that
  opens the shared chat window (setActiveSession + setOpen) on click
- inbox-page merges inboxListOptions + chatSessionsOptions into one feed,
  sorts by recency, and dispatches each row through the registry; notification
  selection / IssueDetail / archive behavior is unchanged
- backed entirely by existing data — no backend changes
- update registry tests for entries + both kinds

Follow-ups: type tags + filters (All/Unread/conversation/notification),
approval + digest item types.

Refs MUL-3788

Co-authored-by: multica-agent <github@multica.ai>
2026-06-28 03:25:33 +08:00
Jiayuan
6e3e7aad23 refactor(inbox): introduce typed item-type renderer registry
Inbox becomes a typed feed: each entry resolves to a registered renderer
that owns its list row + detail pane, while the list/filter/sort/read
machinery and the work-anchor envelope stay shared. This is the keystone
for adding conversation / approval / digest item types (MUL-3788) without
touching the shared inbox machinery.

- add item-types/ contract: InboxItemKind, InboxItemRenderer, registry
  (registerInboxItemType / getInboxItemRenderer), and inboxItemKind()
- register the existing notification behavior as the issue_notification
  renderer (Row reuses InboxListItem; Detail = the no-backing-issue pane
  extracted verbatim from inbox-page)
- dispatch the inbox list rows and the non-issue detail pane through the
  registry; issue-backed notifications still defer to IssueDetail
- behavior-preserving; add registry unit tests

Refs MUL-3788

Co-authored-by: multica-agent <github@multica.ai>
2026-06-28 03:07:47 +08:00
7 changed files with 597 additions and 73 deletions

View File

@@ -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

View File

@@ -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>

View 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");
});
});

View 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;
}

View 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,
});

View 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";

View 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,
});