mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-19 04:38:50 +02:00
Compare commits
2 Commits
main
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ca7b49cf7 | ||
|
|
58f7ffd083 |
@@ -3,7 +3,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2, Pencil, Loader2, Square } from "lucide-react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check, Trash2, Pencil, Loader2, Square } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
@@ -74,9 +74,8 @@ export function ChatWindow() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
// Single sessions cache. The dropdown groups locally into "active" /
|
||||
// "archived" — eliminating the separate active/all queries that used
|
||||
// to drift during the WS-invalidate window.
|
||||
// Single sessions cache — eliminates the separate active/all queries
|
||||
// that used to drift during the WS-invalidate window.
|
||||
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
|
||||
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
|
||||
chatMessagesOptions(activeSessionId ?? ""),
|
||||
@@ -99,9 +98,9 @@ export function ChatWindow() {
|
||||
const pendingTaskId = pendingTask?.task_id ?? null;
|
||||
|
||||
// Legacy archived sessions (the old soft-archive feature was removed but
|
||||
// pre-existing rows with status='archived' may still exist) render as
|
||||
// read-only: dropdown keeps showing them under "archived", but ChatInput
|
||||
// is disabled and the server still rejects POST /messages for them.
|
||||
// pre-existing rows with status='archived' may still exist) are excluded
|
||||
// from the history dropdown. If one is still the active session, ChatInput
|
||||
// is disabled and the server still rejects POST /messages for it.
|
||||
const currentSession = activeSessionId
|
||||
? sessions.find((s) => s.id === activeSessionId)
|
||||
: null;
|
||||
@@ -691,10 +690,9 @@ function AgentMenuItem({
|
||||
}
|
||||
|
||||
/**
|
||||
* Session dropdown: groups all sessions into "active" and "archived". The
|
||||
* archived branch is collapsed by default and only mounts on demand to
|
||||
* keep the menu compact when the user has many old chats. Selecting a
|
||||
* session from a different agent implicitly switches the agent too
|
||||
* Session dropdown: a flat "Chat history" list of all non-archived
|
||||
* sessions. 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.
|
||||
*/
|
||||
@@ -716,18 +714,14 @@ function SessionDropdown({
|
||||
const title = activeSession?.title?.trim() || t(($) => $.window.untitled);
|
||||
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
|
||||
|
||||
const { active, archived } = useMemo(() => {
|
||||
const active: ChatSession[] = [];
|
||||
const archived: ChatSession[] = [];
|
||||
for (const s of sessions) {
|
||||
if (s.status === "archived") archived.push(s);
|
||||
else active.push(s);
|
||||
}
|
||||
return { active, archived };
|
||||
}, [sessions]);
|
||||
// The old soft-archive feature was removed. Pre-existing rows with
|
||||
// status='archived' are legacy dead data and are excluded from history.
|
||||
const historySessions = useMemo(
|
||||
() => sessions.filter((s) => s.status !== "archived"),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
|
||||
const [confirmingStopId, setConfirmingStopId] = useState<string | null>(null);
|
||||
const [stoppingTaskId, setStoppingTaskId] = useState<string | null>(null);
|
||||
@@ -895,9 +889,7 @@ function SessionDropdown({
|
||||
? t(($) => $.session_history.row_subtitle.completed)
|
||||
: showUnread
|
||||
? t(($) => $.session_history.row_subtitle.new_reply)
|
||||
: session.status === "archived"
|
||||
? t(($) => $.session_history.row_subtitle.archived_label)
|
||||
: formatTimeAgo(session.updated_at);
|
||||
: formatTimeAgo(session.updated_at);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -918,7 +910,6 @@ function SessionDropdown({
|
||||
"group/history-row relative flex min-h-11 min-w-0 cursor-default items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2 pr-2 outline-none transition-colors hover:bg-accent/60 focus-visible:bg-accent/60 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isCurrent && "bg-accent/70",
|
||||
isConfirmingAction && "bg-destructive/5 hover:bg-destructive/5",
|
||||
session.status === "archived" && "opacity-75",
|
||||
)}
|
||||
>
|
||||
{isCurrent && <span className="absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-full bg-brand" />}
|
||||
@@ -933,7 +924,7 @@ function SessionDropdown({
|
||||
) : (
|
||||
<span className="size-6 shrink-0" />
|
||||
)}
|
||||
<div className={cn("min-w-0 flex-1", !isRenaming && !isConfirmingAction && "pr-28")}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isRenaming ? (
|
||||
<SessionRenameInput
|
||||
initialValue={session.title ?? ""}
|
||||
@@ -1036,8 +1027,8 @@ function SessionDropdown({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
|
||||
<div className="flex h-7 min-w-16 items-center justify-end gap-1.5 text-xs text-muted-foreground transition-opacity group-hover/history-row:opacity-0">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="flex h-7 items-center justify-end gap-1.5 text-xs text-muted-foreground group-hover/history-row:hidden">
|
||||
{isRunning && <Loader2 className="size-3 animate-spin" />}
|
||||
{showCompleted && !isRunning && <Check className="size-3 text-emerald-500" />}
|
||||
{showUnread && !isRunning && !showCompleted && (
|
||||
@@ -1049,7 +1040,7 @@ function SessionDropdown({
|
||||
)}
|
||||
<span className={cn("truncate", (showUnread || showCompleted || isRunning) && "font-medium text-foreground")}>{trailingStatus}</span>
|
||||
</div>
|
||||
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/history-row:opacity-100">
|
||||
<div className="hidden h-7 items-center gap-0.5 group-hover/history-row:flex">
|
||||
{isRunning && pendingTask && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1164,49 +1155,17 @@ function SessionDropdown({
|
||||
className="max-h-96 w-auto min-w-[max(16rem,var(--anchor-width,16rem))] max-w-96 gap-0 overflow-y-auto p-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
{historySessions.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t(($) => $.window.no_previous)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{active.length > 0 && (
|
||||
<div role="group" aria-label={t(($) => $.window.active_group)}>
|
||||
<div className="px-1.5 py-1 text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.window.active_group)}
|
||||
</div>
|
||||
{active.map(renderRow)}
|
||||
</div>
|
||||
)}
|
||||
{archived.length > 0 && (
|
||||
<>
|
||||
{active.length > 0 && <div className="-mx-1 my-1 h-px bg-border" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowArchived((v) => !v);
|
||||
}}
|
||||
className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-xs text-muted-foreground outline-none transition-colors hover:bg-accent/60 focus-visible:bg-accent/60 focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-expanded={showArchived}
|
||||
>
|
||||
{showArchived ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{t(($) => $.window.archived_group, { count: archived.length })}
|
||||
</span>
|
||||
</button>
|
||||
{showArchived && (
|
||||
<div role="group" aria-label={t(($) => $.window.archived_group, { count: archived.length })}>
|
||||
{archived.map(renderRow)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<div role="group" aria-label={t(($) => $.window.history_group)}>
|
||||
<div className="px-1.5 py-1 text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.window.history_group)}
|
||||
</div>
|
||||
{historySessions.map(renderRow)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -32,8 +32,10 @@ import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
|
||||
// 1. Agent avatar (no status dot — agent availability is not the
|
||||
// story here; the row's right column carries the task status)
|
||||
// 2. Trigger description flexes and truncates
|
||||
// 3. Status is a normal shrink-0 right column; hover actions overlay that
|
||||
// same right edge. Do not use masks/padding gymnastics here.
|
||||
// 3. Status is a normal shrink-0 right column; on hover it is replaced
|
||||
// in place by the action buttons (status is removed, not covered).
|
||||
// Left text keeps flex-1 so the row never shows a mid-row gap. Do
|
||||
// not use masks/padding gymnastics here.
|
||||
//
|
||||
// One query (`listTasksByIssue`) drives both buckets — the back-end
|
||||
// returns every status, the front-end filters into active vs past on the
|
||||
@@ -272,8 +274,14 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
|
||||
<RowShell task={task}>
|
||||
<TriggerText text={trigger} />
|
||||
<RowStatus title={label}>
|
||||
{task.status === "running" && <Loader2 className="h-3 w-3 animate-spin text-info" />}
|
||||
<span className={`${tone} min-w-0 truncate`}>{label}</span>
|
||||
{task.status === "running" ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin text-info" />
|
||||
<span className="sr-only">{label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={`${tone} min-w-0 truncate`}>{label}</span>
|
||||
)}
|
||||
</RowStatus>
|
||||
<RowActions>
|
||||
{showTranscript && (
|
||||
@@ -401,10 +409,8 @@ function RowShell({
|
||||
task: AgentTask;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// `relative` so the absolute-positioned RowActions slot anchors to this
|
||||
// row instead of an outer container.
|
||||
return (
|
||||
<div className="group/execution-log-row relative flex items-center gap-2 overflow-hidden rounded px-1 py-1.5 transition-colors hover:bg-accent/40">
|
||||
<div className="group/execution-log-row flex items-center gap-2 overflow-hidden rounded px-1 py-1.5 transition-colors hover:bg-accent/40">
|
||||
{task.agent_id ? (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
@@ -434,7 +440,7 @@ function RowStatus({
|
||||
return (
|
||||
<div
|
||||
title={title}
|
||||
className="flex h-7 w-20 shrink-0 items-center justify-end gap-1 overflow-hidden whitespace-nowrap text-xs transition-opacity group-hover/execution-log-row:opacity-0 group-focus-within/execution-log-row:opacity-0"
|
||||
className="flex h-7 shrink-0 items-center justify-end gap-1 overflow-hidden whitespace-nowrap text-xs group-hover/execution-log-row:hidden"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -454,21 +460,12 @@ function TaskStatusIcon({ status }: { status: AgentTask["status"] }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hover-only action slot — absolute-positioned over the row's right edge.
|
||||
// It covers the normal right status column only on hover.
|
||||
// Action slot — hidden by default, replaces the status column in place on
|
||||
// hover. No absolute/gradient needed: the status is removed (not covered),
|
||||
// so nothing shows through underneath.
|
||||
function RowActions({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"pointer-events-none absolute inset-y-0 right-1 flex w-20 items-center justify-end gap-0.5 opacity-0 transition-opacity",
|
||||
// The gradient backdrop blends the row's hover background (accent/40)
|
||||
// from the right and fades to transparent on the left, so the
|
||||
// status text underneath is dimmed gracefully rather than cut.
|
||||
"bg-gradient-to-l from-accent/95 via-accent/80 to-transparent",
|
||||
"group-hover/execution-log-row:pointer-events-auto group-hover/execution-log-row:opacity-100",
|
||||
"group-focus-within/execution-log-row:pointer-events-auto group-focus-within/execution-log-row:opacity-100",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="hidden h-7 items-center gap-0.5 group-hover/execution-log-row:flex">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,8 +43,7 @@
|
||||
"row_subtitle": {
|
||||
"working": "Working",
|
||||
"completed": "Completed",
|
||||
"new_reply": "New reply",
|
||||
"archived_label": "Archived"
|
||||
"new_reply": "New reply"
|
||||
},
|
||||
"stop_dialog": {
|
||||
"title": "Stop this run?",
|
||||
@@ -77,9 +76,7 @@
|
||||
"my_agents": "My agents",
|
||||
"others": "Others",
|
||||
"no_agents": "No agents",
|
||||
"active_group": "Active",
|
||||
"archived_group_one": "{{count}} archived chat",
|
||||
"archived_group_other": "{{count}} archived chats"
|
||||
"history_group": "Chat history"
|
||||
},
|
||||
"empty_state": {
|
||||
"first_time_title": "Chat with your agents",
|
||||
|
||||
@@ -43,8 +43,7 @@
|
||||
"row_subtitle": {
|
||||
"working": "작업 중",
|
||||
"completed": "완료됨",
|
||||
"new_reply": "새 답변",
|
||||
"archived_label": "보관됨"
|
||||
"new_reply": "새 답변"
|
||||
},
|
||||
"stop_dialog": {
|
||||
"title": "이 실행을 중지할까요?",
|
||||
@@ -77,9 +76,7 @@
|
||||
"my_agents": "내 에이전트",
|
||||
"others": "기타",
|
||||
"no_agents": "에이전트 없음",
|
||||
"active_group": "활성",
|
||||
"archived_group_one": "보관된 채팅 {{count}}개",
|
||||
"archived_group_other": "보관된 채팅 {{count}}개"
|
||||
"history_group": "채팅 기록"
|
||||
},
|
||||
"empty_state": {
|
||||
"first_time_title": "에이전트와 채팅하기",
|
||||
|
||||
@@ -40,8 +40,7 @@
|
||||
"row_subtitle": {
|
||||
"working": "运行中",
|
||||
"completed": "已完成",
|
||||
"new_reply": "新回复",
|
||||
"archived_label": "已归档"
|
||||
"new_reply": "新回复"
|
||||
},
|
||||
"stop_dialog": {
|
||||
"title": "停止这次运行?",
|
||||
@@ -74,8 +73,7 @@
|
||||
"my_agents": "我的智能体",
|
||||
"others": "其他",
|
||||
"no_agents": "暂无智能体",
|
||||
"active_group": "进行中",
|
||||
"archived_group_other": "{{count}} 条已归档"
|
||||
"history_group": "历史对话"
|
||||
},
|
||||
"empty_state": {
|
||||
"first_time_title": "和你的智能体对话",
|
||||
|
||||
Reference in New Issue
Block a user