Compare commits

...

4 Commits

Author SHA1 Message Date
Naiyuan Qing
6a91c4379d Merge pull request #996 from multica-ai/feat/chat-unread-priority
feat(chat): float unread sessions to the top
2026-04-14 19:17:31 +08:00
Naiyuan Qing
fa62b81d33 Merge pull request #997 from multica-ai/feat/chat-agent-unavailable
fix(chat): handle unavailable agent end-to-end
2026-04-14 19:11:38 +08:00
Naiyuan Qing
274b342db6 fix(chat): handle unavailable agent end-to-end
Previously several failure modes leaked through silently:
- Workspace with no agents → input still writable, send did nothing
- Session's agent archived → the fallback avatar UI disagreed with
  what the session was actually bound to; send then threw in the
  console with no user feedback
- Any backend send failure (archived, no runtime, …) → no toast

Replaces the ad-hoc `activeAgent` with a derived `agentState`:

  { agent: Agent | null, canSend: boolean, reason?: "no_agents"
    | "archived" | "missing" }

Priority when resolving:
1. If a session is active, bind to its agent from the FULL agent list
   (so archived agents are still rendered, read-only).
2. Otherwise pick the preferred agent from available ones, falling
   back to first-available or null.

UI consumes it uniformly:
- Input is disabled whenever canSend is false
- Placeholder explains why (archived / no agents / missing)
- Empty state replaces starter prompts with a "create an agent" hint
  when the workspace has none
- handleSend wraps the API call in try/catch and surfaces the error
  via sonner; optimistic user bubble is cleared on failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:06:47 +08:00
Naiyuan Qing
63fa5c3e0e feat(chat): float unread sessions to the top of the list
Sort is now (unread_since IS NOT NULL) DESC, updated_at DESC. An unread
reply never gets buried below a newer action on a different session the
user already caught up on.

Within each group (unread and caught-up) the existing most-recent-first
order is preserved. Once the user opens the session and it's marked
read, it falls back to its time-ordered position; this shift is
intentional and won't be noticed because nothing points at it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:54:06 +08:00
4 changed files with 180 additions and 60 deletions

View File

@@ -14,8 +14,15 @@ interface ChatInputProps {
onStop?: () => void;
isRunning?: boolean;
disabled?: boolean;
/** Name of the currently selected agent, used in the placeholder. */
/** Name of the currently selected agent, used in the default placeholder. */
agentName?: string;
/**
* Full override for the placeholder text. When present, supersedes the
* agentName-based default and the archived-session message. Caller uses
* this to communicate agent-availability reasons (archived agent,
* no_agents, etc.).
*/
placeholderOverride?: string;
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
leftAdornment?: ReactNode;
}
@@ -26,6 +33,7 @@ export function ChatInput({
isRunning,
disabled,
agentName,
placeholderOverride,
leftAdornment,
}: ChatInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
@@ -66,11 +74,13 @@ export function ChatInput({
setIsEmpty(true);
};
const placeholder = disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
const placeholder =
placeholderOverride ??
(disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…");
return (
<div className="px-5 pb-3 pt-0">

View File

@@ -34,11 +34,62 @@ import { ChatInput } from "./chat-input";
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { createLogger } from "@multica/core/logger";
import { toast } from "sonner";
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
/**
* What we know about the agent the UI is currently tied to, plus whether
* the user can actually send in this state. Derived each render from the
* current session, selected agent, and available agents.
*/
type AgentUnavailableReason =
| "no_agents" // workspace has no available agents at all
| "archived" // agent exists but is archived (read-only session)
| "missing"; // session refers to an agent that no longer exists
interface AgentState {
/** Agent to display (possibly archived). Null when nothing to show. */
agent: Agent | null;
/** Whether the user can send a message in this state. */
canSend: boolean;
/** Why the user can't send. Absent when canSend is true. */
reason?: AgentUnavailableReason;
}
function sendBlockedMessage(reason: AgentUnavailableReason | undefined): string {
switch (reason) {
case "no_agents":
return "No agents available — create one first";
case "archived":
return "This agent is archived and can't receive messages";
case "missing":
return "This session's agent no longer exists";
default:
return "Can't send right now";
}
}
function placeholderFor(
reason: AgentUnavailableReason | undefined,
agentName: string | undefined,
isSessionArchived: boolean,
): string {
if (isSessionArchived) return "This session is archived";
switch (reason) {
case "no_agents":
return "Create an agent to start chatting";
case "archived":
return "This agent is archived — conversation is read-only";
case "missing":
return "This session's agent is no longer available";
default:
return agentName ? `Tell ${agentName} what to do…` : "Tell me what to do…";
}
}
export function ChatWindow() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
@@ -72,12 +123,6 @@ export function ChatWindow() {
);
const pendingTaskId = pendingTask?.task_id ?? null;
// Check if current session is archived
const currentSession = activeSessionId
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
const qc = useQueryClient();
const createSession = useCreateChatSession();
const markRead = useMarkChatSessionRead();
@@ -88,11 +133,33 @@ export function ChatWindow() {
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
);
// Resolve selected agent: stored preference → first available
const activeAgent =
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
// Current session (may be null for a fresh new chat). Used both to bound
// the agent we show and to flag read-only sessions below.
const currentSession = activeSessionId
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
// Resolve which agent the UI is tied to, plus whether the user can send.
// Priority when a session is active: the session's bound agent from the
// FULL list (may be archived — we still render it, read-only). Without a
// session we pick the user's preference from the available set.
const agentState = useMemo<AgentState>(() => {
if (currentSession) {
const bound = agents.find((a) => a.id === currentSession.agent_id) ?? null;
if (!bound) return { agent: null, canSend: false, reason: "missing" };
if (bound.archived_at) return { agent: bound, canSend: false, reason: "archived" };
return { agent: bound, canSend: true };
}
const picked =
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
if (picked) return { agent: picked, canSend: true };
return { agent: null, canSend: false, reason: "no_agents" };
}, [currentSession, agents, availableAgents, selectedAgentId]);
const activeAgent = agentState.agent;
// Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
// fires on layout mount (login / workspace switch / fresh page load).
@@ -156,8 +223,11 @@ export function ChatWindow() {
const handleSend = useCallback(
async (content: string) => {
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
if (!agentState.canSend || !activeAgent) {
apiLogger.warn("sendChatMessage skipped", { reason: agentState.reason });
// Surface why — handleSend is usually triggered by button or Enter,
// silent failure is confusing.
toast.error(sendBlockedMessage(agentState.reason));
return;
}
@@ -171,47 +241,59 @@ export function ChatWindow() {
contentLength: content.length,
});
if (!sessionId) {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: content.slice(0, 50),
try {
if (!sessionId) {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: content.slice(0, 50),
});
sessionId = session.id;
setActiveSession(sessionId);
}
// Optimistic: show user message immediately.
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content,
task_id: null,
created_at: new Date().toISOString(),
};
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
const result = await api.sendChatMessage(sessionId, content);
apiLogger.info("sendChatMessage.success", {
sessionId,
messageId: result.message_id,
taskId: result.task_id,
});
sessionId = session.id;
setActiveSession(sessionId);
// 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) });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
apiLogger.error("sendChatMessage.error", { err });
// Drop the optimistic message — refetch the real list so the user's
// bubble doesn't dangle without a reply.
if (sessionId) {
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
}
toast.error(`Failed to send: ${message}`);
}
// Optimistic: show user message immediately.
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content,
task_id: null,
created_at: new Date().toISOString(),
};
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
const result = await api.sendChatMessage(sessionId, content);
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) });
},
[
activeSessionId,
activeAgent,
agentState,
createSession,
setActiveSession,
qc,
@@ -390,17 +472,23 @@ export function ChatWindow() {
) : (
<EmptyState
agentName={activeAgent?.name}
reason={agentState.reason}
onPickPrompt={(text) => handleSend(text)}
/>
)}
{/* Input — disabled for archived sessions */}
{/* Input — disabled for archived sessions or when no agent can accept */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
disabled={isSessionArchived || !agentState.canSend}
agentName={activeAgent?.name}
placeholderOverride={placeholderFor(
agentState.reason,
activeAgent?.name,
!!isSessionArchived,
)}
leftAdornment={
<AgentDropdown
agents={availableAgents}
@@ -597,11 +685,25 @@ const STARTER_PROMPTS: { icon: string; text: string }[] = [
function EmptyState({
agentName,
reason,
onPickPrompt,
}: {
agentName?: string;
reason?: AgentUnavailableReason;
onPickPrompt: (text: string) => void;
}) {
// Can't chat → show the reason instead of the starter prompts.
if (reason === "no_agents") {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 py-8 text-center">
<h3 className="text-base font-semibold">No agents yet</h3>
<p className="text-sm text-muted-foreground max-w-xs">
Create an agent from the Agents tab to start chatting.
</p>
</div>
);
}
return (
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
<div className="text-center space-y-1">

View File

@@ -250,7 +250,7 @@ SELECT cs.id, cs.workspace_id, cs.agent_id, cs.creator_id, cs.title, cs.session_
(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
ORDER BY (cs.unread_since IS NOT NULL) DESC, cs.updated_at DESC
`
type ListAllChatSessionsByCreatorParams struct {
@@ -273,6 +273,8 @@ type ListAllChatSessionsByCreatorRow struct {
HasUnread bool `json:"has_unread"`
}
// Unread sessions float to the top so new activity never gets buried
// under routine reads; within each group, most-recent activity wins.
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 {
@@ -344,7 +346,7 @@ SELECT cs.id, cs.workspace_id, cs.agent_id, cs.creator_id, cs.title, cs.session_
(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
ORDER BY (cs.unread_since IS NOT NULL) DESC, cs.updated_at DESC
`
type ListChatSessionsByCreatorParams struct {
@@ -370,6 +372,8 @@ type ListChatSessionsByCreatorRow struct {
// 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.
// Unread sessions float to the top so new activity never gets buried
// under routine reads; within each group, most-recent activity wins.
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 {

View File

@@ -19,14 +19,18 @@ 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;
-- Unread sessions float to the top so new activity never gets buried
-- under routine reads; within each group, most-recent activity wins.
ORDER BY (cs.unread_since IS NOT NULL) DESC, cs.updated_at DESC;
-- name: ListAllChatSessionsByCreator :many
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;
-- Unread sessions float to the top so new activity never gets buried
-- under routine reads; within each group, most-recent activity wins.
ORDER BY (cs.unread_since IS NOT NULL) DESC, cs.updated_at DESC;
-- name: UpdateChatSessionTitle :one
UPDATE chat_session SET title = $2, updated_at = now()