Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
3ca7b49cf7 refactor(issues): align execution log rows with the chat hover-swap pattern
- Drop the fixed w-20 status column that forced premature truncation of
  the trigger text and left a mid-row gap; status now sizes to content.
- Running tasks render only the spinner (sr-only label retained for a11y
  and tooltip); the redundant "Working" text is removed.
- Hover swaps status for actions in place (RowStatus hidden, RowActions
  inline) instead of an absolute gradient overlay. Applies to both
  active and past ("show past runs") rows via the shared RowShell /
  RowStatus / RowActions.

Known tradeoff: dropping the absolute+opacity slot also drops the
group-focus-within keyboard reveal, so cancel/retry are no longer
Tab-reachable. Matches the chat pattern; revisit if keyboard access for
row actions becomes a requirement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:43:14 +08:00
Naiyuan Qing
58f7ffd083 refactor(chat): rework chat history list
- Drop legacy archived sessions from the history dropdown. The
  soft-archive feature was removed, so status='archived' rows are dead
  data; exclude them instead of showing a collapsed "archived" group.
  Rename the section heading "Active" -> "Chat history".
- Swap hover row actions into the status column's slot instead of an
  absolute overlay: status is hidden on hover and actions take its
  place inline, while the title keeps flex-1. No mid-row gap, no
  overlap, no text bleed-through.
- Remove orphaned i18n keys (active_group, archived_group,
  archived_label) across en/zh-Hans/ko.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:42:56 +08:00
5 changed files with 51 additions and 103 deletions

View File

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

View File

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

View File

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

View File

@@ -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": "에이전트와 채팅하기",

View File

@@ -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": "和你的智能体对话",