Compare commits

...

3 Commits

Author SHA1 Message Date
Naiyuan Qing
0f705b270e refactor(issues): swap indicator ring pulse for shimmer text label
Earlier iterations layered a brand ring with various opacity-pulse
cadences around the per-issue avatar stack. Every tuning attempt was
either invisible (transparent ring + faded pulse) or oppressive (a
visible ring that flashed on a dense board). Moves the "alive" signal
onto a small text label and reuses chat's existing
`animate-chat-text-shimmer` utility — a soft light sweep across the
glyphs that already powers the ChatGPT-style "thinking" cue in
task-status-pill.

Indicator now reads as a 12 px avatar stack + 10 px label:

- Running → full-opacity avatars + shimmering localized "Working"
- Queued  → half-opacity avatars + muted static "Queued"
- Idle    → render nothing (unchanged)

Avatars and the surrounding card stay completely still; only the few
glyphs animate. The label is i18n-driven via the existing
`status_running` / `status_queued` keys, so no locale changes are
required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:15:11 +08:00
Naiyuan Qing
64aec1d58f feat(issues): live agent activity chip + per-issue indicator + filter
Surfaces "which agents are working on what, right now" in the Issues
and My Issues views, with a one-click filter to narrow the list to
issues that have a running agent task.

Two visual surfaces:

- **Workspace chip** in the header (left of Filter). Shows the
  brand-tinted avatar stack of agents currently running on visible
  issues. Click toggles a page-scoped filter; idle state renders a
  static "0 working" button with a hover-card placeholder. When the
  filter is active the chip pins to brand fill across hover and popover
  states (the Button outline variant otherwise repaints back to
  neutral). A muted "Viewing only working agents" hint sits to the
  left of the chip whenever the filter is on, so users notice the
  active state without having to hover.

- **Per-issue indicator** on every board card and list row (top-right
  of the identifier line). Renders the avatar stack of agents in
  running or queued state on that issue, full-opacity ring at brand/70
  when ≥1 is running, half-opacity stack when only queued. Returns
  null when nothing is in flight.

Both surfaces open the same hover-card body that lists each active
task with the agent avatar, status dot (composed via the existing
availability + workload tokens), and a live-ticking duration.

Adds a new "All" scope to /my-issues that unions assignee, creator,
and involves_user_id via three parallel fetches deduped on the
client — no backend changes for this part. The chip's count and the
quick-filter both use the page's currently visible issue ids so they
stay in sync with the active scope.

State is per-user (Zustand + localStorage) and the agentRunningFilter
is intentionally omitted from partialize — running state changes
second-to-second and a stored toggle would land users in an
unexplained empty list. WS task:running, already added in the
preceding commit, drives real-time updates without polling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:09:05 +08:00
Naiyuan Qing
e5db641fd8 feat(server): broadcast task:running event
The dispatched → running transition was silent: only task:queued,
task:dispatch, task:cancelled, task:completed and task:failed
broadcast over WS. Any UI that distinguishes "queued" from "running"
(e.g. the new issue-card agent activity indicator) would lag by up to
the 30s agentTaskSnapshot staleTime on the most user-visible
transition. StartTask now broadcasts task:running so the workspace
snapshot invalidates immediately, keeping the agent activity UI live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:08:37 +08:00
21 changed files with 843 additions and 18 deletions

View File

@@ -2,6 +2,7 @@ import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type {
GroupedIssuesResponse,
Issue,
IssueStatus,
ListGroupedIssuesParams,
ListIssuesParams,
@@ -104,6 +105,102 @@ async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesC
return { byStatus };
}
/**
* "All my issues" — union of three server filters:
* assignee_id=me OR creator_id=me OR involves_user_id=me
*
* The backend has no OR-across-user-filters today, so we run the three
* existing single-filter fetches in parallel and dedupe on the client by
* issue id within each status bucket. Order within each bucket preserves
* the first-seen position (each sub-fetch is already server-sorted).
*
* Personal lists are bounded (tens to a few hundred issues across all
* three relations), so 3× the request count is acceptable — a single
* fetchFirstPages already runs 7 status fetches in parallel, so the total
* here is 21 small parallel requests. Easy enough; no need to add a new
* backend query just for this scope.
*
* `total` per bucket is set to the merged length, not the true server
* total — pagination on the "All" scope is out of scope; the first
* 50-per-status × 3 widening (deduped) is what the page renders.
*/
async function fetchAllMyFirstPages(userId: string): Promise<ListIssuesCache> {
const [byAssignee, byCreator, byInvolves] = await Promise.all([
fetchFirstPages({ assignee_id: userId }),
fetchFirstPages({ creator_id: userId }),
fetchFirstPages({ involves_user_id: userId }),
]);
const byStatus: ListIssuesCache["byStatus"] = {};
for (const status of PAGINATED_STATUSES) {
const seen = new Set<string>();
const merged: Issue[] = [];
for (const cache of [byAssignee, byCreator, byInvolves]) {
const bucket = cache.byStatus[status];
if (!bucket) continue;
for (const issue of bucket.issues) {
if (seen.has(issue.id)) continue;
seen.add(issue.id);
merged.push(issue);
}
}
byStatus[status] = { issues: merged, total: merged.length };
}
return { byStatus };
}
/**
* Sibling of {@link fetchAllMyFirstPages} for the assignee-grouped board
* view. Runs the three single-filter grouped queries in parallel and
* merges groups by (assignee_type, assignee_id), deduping issues within
* each group. Extra filters from the page (statuses, priorities, etc.)
* pass through unchanged.
*/
async function fetchAllMyAssigneeGroups(
userId: string,
filter: AssigneeGroupedIssuesFilter,
): Promise<GroupedIssuesResponse> {
const variants: AssigneeGroupedIssuesFilter[] = [
{ ...filter, assignee_id: userId },
{ ...filter, creator_id: userId },
{ ...filter, involves_user_id: userId },
];
const responses = await Promise.all(
variants.map((f) =>
api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...f,
}),
),
);
const groupKey = (g: GroupedIssuesResponse["groups"][number]) =>
`${g.assignee_type ?? "_"}::${g.assignee_id ?? "_"}`;
const merged = new Map<string, GroupedIssuesResponse["groups"][number]>();
for (const res of responses) {
for (const group of res.groups) {
const key = groupKey(group);
const existing = merged.get(key);
if (!existing) {
merged.set(key, {
...group,
issues: [...group.issues],
total: group.issues.length,
});
continue;
}
const seen = new Set(existing.issues.map((i) => i.id));
for (const issue of group.issues) {
if (seen.has(issue.id)) continue;
seen.add(issue.id);
existing.issues.push(issue);
}
existing.total = existing.issues.length;
}
}
return { groups: [...merged.values()] };
}
/**
* CACHE SHAPE NOTE: The raw cache stores {@link ListIssuesCache} (buckets keyed
* by status, each with `{ issues, total }`), and `select` flattens it to
@@ -145,10 +242,18 @@ export function myIssueListOptions(
wsId: string,
scope: string,
filter: MyIssuesFilter,
// Required when scope === "all" — the user id whose three relations
// (assignee, creator, agents+squads) we union over. For every other
// scope the filter object already carries the relevant id and userId
// is ignored.
userId?: string,
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),
queryFn: () => fetchFirstPages(filter),
queryFn: () =>
scope === "all" && userId
? fetchAllMyFirstPages(userId)
: fetchFirstPages(filter),
select: flattenIssueBuckets,
});
}
@@ -210,16 +315,21 @@ export function myIssueAssigneeGroupsOptions(
wsId: string,
scope: string,
filter: AssigneeGroupedIssuesFilter,
// See myIssueListOptions for the userId contract — only consulted when
// scope === "all", and powers the 3-fetch grouped union.
userId?: string,
) {
return queryOptions<GroupedIssuesResponse>({
queryKey: issueKeys.myAssigneeGroups(wsId, scope, filter),
queryFn: () =>
api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...filter,
}),
scope === "all" && userId
? fetchAllMyAssigneeGroups(userId, filter)
: api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...filter,
}),
});
}

View File

@@ -10,7 +10,7 @@ import {
} from "./view-store";
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
export type MyIssuesScope = "assigned" | "created" | "agents";
export type MyIssuesScope = "all" | "assigned" | "created" | "agents";
export interface MyIssuesViewState extends IssueViewState {
scope: MyIssuesScope;

View File

@@ -67,6 +67,12 @@ export interface IssueViewState {
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
// When true, the list only shows issues that currently have at least one
// agent task in `running` status. Drives the workspace "agents working"
// quick filter chip in the issues header. Not persisted across reloads —
// running state changes second-to-second, a persisted toggle would let
// users return to an empty list with no obvious cause.
agentRunningFilter: boolean;
sortBy: SortField;
sortDirection: SortDirection;
cardProperties: CardProperties;
@@ -85,6 +91,7 @@ export interface IssueViewState {
toggleProjectFilter: (projectId: string) => void;
toggleNoProject: () => void;
toggleLabelFilter: (labelId: string) => void;
toggleAgentRunningFilter: () => void;
hideStatus: (status: IssueStatus) => void;
showStatus: (status: IssueStatus) => void;
clearFilters: () => void;
@@ -105,6 +112,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
projectFilters: [],
includeNoProject: false,
labelFilters: [],
agentRunningFilter: false,
sortBy: "position",
sortDirection: "asc",
cardProperties: {
@@ -180,6 +188,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
? state.labelFilters.filter((id) => id !== labelId)
: [...state.labelFilters, labelId],
})),
toggleAgentRunningFilter: () =>
set((state) => ({ agentRunningFilter: !state.agentRunningFilter })),
hideStatus: (status) =>
set((state) => {
// If no filter active, activate filter with all EXCEPT this one
@@ -206,6 +216,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
projectFilters: [],
includeNoProject: false,
labelFilters: [],
agentRunningFilter: false,
}),
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
@@ -228,6 +239,10 @@ export const viewStorePersistOptions = (name: string) => ({
name,
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state: IssueViewState) => ({
// NOTE: `agentRunningFilter` is intentionally NOT persisted — running
// state changes second-to-second, and a stored toggle would let users
// return to an unexplained empty list. Keep it ephemeral. See the
// field comment on IssueViewState.
viewMode: state.viewMode,
grouping: state.grouping,
statusFilters: state.statusFilters,

View File

@@ -0,0 +1,154 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
import { useActorName } from "@multica/core/workspace/hooks";
import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { agentListOptions } from "@multica/core/workspace/queries";
import { deriveAgentAvailability } from "@multica/core/agents";
import type { AgentTask } from "@multica/core/types";
import { workloadConfig } from "../presence";
import { useT } from "../../i18n";
interface AgentActivityHoverContentProps {
// Active tasks (running / queued / dispatched) to render — caller filters
// by issue id or by workspace scope. Order is preserved; we render every
// task as its own row.
tasks: readonly AgentTask[];
}
/**
* Shared hover-card body for "what are these agents doing right now?" — used
* by IssueAgentActivityIndicator (per-issue) and WorkspaceAgentWorkingChip
* (workspace-wide). One row per task: agent avatar, name, status dot,
* status label, duration.
*
* Status colour follows the workspace's existing composition rule:
* - running → brand (text-brand)
* - queued, runtime online → muted gray (transient race)
* - queued, runtime offline/etc. → warning amber (genuine stuck)
* — same rule as agent-presence-indicator.tsx so users see a single,
* consistent language for "agent is in trouble" vs "just enqueued".
*/
export function AgentActivityHoverContent({
tasks,
}: AgentActivityHoverContentProps) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const { getActorName, getActorInitials, getActorAvatarUrl } = useActorName();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
// Tick `now` once per second so the per-task duration label updates
// live while the hover card is open. setInterval only runs while the
// hover card is mounted (Base UI portals the content but tears it down
// on close), so this costs nothing when the card is closed.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// Build O(1) lookups so each task row resolves agent + runtime without
// an N×M scan. Cheap — agents/runtimes count in tens at most.
const agentById = new Map(agents.map((a) => [a.id, a] as const));
const runtimeById = new Map(runtimes.map((r) => [r.id, r] as const));
if (tasks.length === 0) return null;
return (
<div className="flex flex-col gap-2">
<div className="text-xs font-medium text-muted-foreground">
{t(($) => $.agent_activity.hover_header, { count: tasks.length })}
</div>
<div className="flex flex-col gap-1.5">
{tasks.map((task) => {
const agent = agentById.get(task.agent_id);
const runtime = runtimeFrom(agent?.runtime_id, runtimeById);
const availability = deriveAgentAvailability(runtime, now);
const isRunning = task.status === "running";
// queued/dispatched both read as "queued" in the user-facing
// copy — `dispatched` is the daemon-acked sub-state of queued
// and not user-meaningful here.
const wl = isRunning ? workloadConfig.working : workloadConfig.queued;
// queued + online → muted gray (transient race, no warning);
// queued + offline/unstable → keep warning amber from
// workloadConfig. Mirrors agent-presence-indicator.tsx.
const dotClass = isRunning
? "bg-brand"
: availability === "online"
? "bg-muted-foreground/40"
: "bg-warning";
const labelClass = isRunning
? wl.textClass
: availability === "online"
? "text-muted-foreground"
: wl.textClass;
const startedFrom = isRunning
? (task.started_at ?? task.dispatched_at ?? task.created_at)
: task.created_at;
return (
<div
key={task.id}
className="flex items-center gap-2 text-xs"
>
<ActorAvatarBase
name={getActorName("agent", task.agent_id)}
initials={getActorInitials("agent", task.agent_id)}
avatarUrl={getActorAvatarUrl("agent", task.agent_id)}
isAgent
size={18}
/>
<span className="flex-1 truncate font-medium">
{getActorName("agent", task.agent_id)}
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
<span className={labelClass}>
{isRunning
? t(($) => $.agent_activity.status_running)
: t(($) => $.agent_activity.status_queued)}
</span>
<span className="tabular-nums text-muted-foreground">
{formatDuration(startedFrom, now)}
</span>
</span>
</div>
);
})}
</div>
</div>
);
}
function runtimeFrom<T extends { id: string }>(
id: string | undefined,
byId: Map<string, T>,
): T | null {
if (!id) return null;
return byId.get(id) ?? null;
}
// Compact `2m 14s` / `45s` / `1h 03m` duration since the given ISO string.
// Capped at hours — anything over a day for a running task is a sign of a
// stuck runtime, but the hover card is not the place to relitigate that;
// the row will read as `26h 12m` and the user can act.
function formatDuration(fromIso: string, nowMs: number): string {
const start = new Date(fromIso).getTime();
if (!Number.isFinite(start)) return "";
const sec = Math.max(0, Math.round((nowMs - start) / 1000));
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
const remSec = sec % 60;
if (min < 60) return `${min}m ${pad2(remSec)}s`;
const hr = Math.floor(min / 60);
const remMin = min % 60;
return `${hr}h ${pad2(remMin)}m`;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
import { useActorName } from "@multica/core/workspace/hooks";
import { cn } from "@multica/ui/lib/utils";
interface AgentAvatarStackProps {
// Agent ids to render, in display order. The component does NOT dedupe —
// callers are expected to pass a unique list (`new Set(...)` upstream).
agentIds: readonly string[];
// Diameter in px. Avatars overlap by ~30% so the visible spacing scales
// naturally with size. Defaults match a compact toolbar / card-corner
// density (18 px).
size?: number;
// Maximum head count before collapsing the tail into a `+N` chip. Three
// is the plan default — beyond that the stack visually crowds.
max?: number;
// `half` drops opacity to 50%. Used by IssueAgentActivityIndicator to
// signal a queued-only state (no running task) — same heads, weakened
// visual.
opacity?: "full" | "half";
className?: string;
}
/**
* Overlapping avatar group for agents. Pure presentational — no data
* fetching, no hover handling. Wrap it in a HoverCardTrigger upstream
* (IssueAgentActivityIndicator / WorkspaceAgentWorkingChip) to surface
* per-agent detail.
*
* `agentIds` is the full input list. We render up to `max` heads; if the
* input is longer, we drop the tail and append a `+N` overflow chip styled
* to match the avatar dimensions.
*/
export function AgentAvatarStack({
agentIds,
size = 18,
max = 3,
opacity = "full",
className,
}: AgentAvatarStackProps) {
const { getActorName, getActorInitials, getActorAvatarUrl } = useActorName();
if (agentIds.length === 0) return null;
const visible = agentIds.slice(0, max);
const overflow = agentIds.length - visible.length;
// 30% overlap reads as "stacked" without obscuring the next avatar's icon.
const overlap = Math.round(size * 0.3);
return (
<span
className={cn(
"inline-flex items-center",
opacity === "half" && "opacity-50",
className,
)}
style={{ paddingLeft: 0 }}
>
{visible.map((id, i) => (
<span
key={id}
// Each subsequent head sits negative-margin over the previous so
// the stack collapses horizontally instead of growing linearly.
style={{ marginLeft: i === 0 ? 0 : -overlap }}
className="ring-2 ring-background rounded-full inline-flex"
>
<ActorAvatarBase
name={getActorName("agent", id)}
initials={getActorInitials("agent", id)}
avatarUrl={getActorAvatarUrl("agent", id)}
isAgent
size={size}
/>
</span>
))}
{overflow > 0 && (
<span
style={{
marginLeft: -overlap,
width: size,
height: size,
fontSize: Math.max(9, Math.round(size * 0.45)),
}}
className="ring-2 ring-background rounded-full bg-muted text-muted-foreground inline-flex items-center justify-center font-medium tabular-nums"
aria-label={`${overflow} more`}
>
+{overflow}
</span>
)}
</span>
);
}

View File

@@ -23,6 +23,7 @@ import { ProgressRing } from "./progress-ring";
import type { ChildProgress } from "./list-row";
import { IssueActionsContextMenu } from "../actions";
import { LabelChip } from "../../labels/label-chip";
import { IssueAgentActivityIndicator } from "./issue-agent-activity-indicator";
import { useT } from "../../i18n";
function formatDate(date: string): string {
@@ -105,8 +106,11 @@ export const BoardCardContent = memo(function BoardCardContent({
return (
<div className="rounded-lg border-[0.5px] border-border bg-card py-3 px-2.5 shadow-[0_3px_6px_-2px_rgba(0,0,0,0.02),0_1px_1px_0_rgba(0,0,0,0.04)] transition-colors group-hover/card:border-accent group-hover/card:bg-accent group-data-[popup-open]/card:border-accent group-data-[popup-open]/card:bg-accent">
{/* Row 1: Identifier */}
<p className="text-xs text-muted-foreground">{issue.identifier}</p>
{/* Row 1: Identifier + agent activity indicator (top-right) */}
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{issue.identifier}</p>
<IssueAgentActivityIndicator issueId={issue.id} />
</div>
{/* Row 2: Title */}
<p className="mt-1 text-sm font-medium leading-snug line-clamp-2">

View File

@@ -0,0 +1,116 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
} from "@multica/ui/components/ui/hover-card";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import type { AgentTask } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
import { AgentActivityHoverContent } from "../../agents/components/agent-activity-hover-content";
import { useT } from "../../i18n";
interface IssueAgentActivityIndicatorProps {
issueId: string;
// Avatar size in px. Kept very small — this is a corner-of-card cue,
// not a primary control. Default 12 reads as a dot at typical board
// densities while still showing the agent's face on hover-zoom.
size?: number;
}
/**
* Small "is there an agent working on this issue right now" badge shown
* in the top-right of board cards and right after the identifier in list
* rows. Derives state from the workspace-wide agent task snapshot:
*
* - has ≥1 running task → tiny avatar stack + shimmering "Working"
* - 0 running, ≥1 queued → half-opacity stack + muted "Queued"
* - nothing → return null (no chrome, no placeholder)
*
* The shimmer reuses chat's `animate-chat-text-shimmer` utility (defined
* in packages/ui/styles/base.css). Earlier iterations layered a brand
* ring + opacity pulse around the avatars; both read as nervous on a
* dense board. Moving the "alive" signal onto the label keeps the
* avatars themselves still and lets the cue ride a piece of text the
* user can already read.
*
* Hover opens AgentActivityHoverContent which lists every active task
* with status dot + duration. No link rows — the card itself is the
* navigation target for issue detail.
*
* Re-renders on every snapshot invalidation (WS task:* events drive it
* via use-realtime-sync). 30s staleTime is the offline fallback only.
*/
export function IssueAgentActivityIndicator({
issueId,
size = 12,
}: IssueAgentActivityIndicatorProps) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const { runningTasks, queuedTasks, agentIds, opacity } = useMemo(() => {
const running: AgentTask[] = [];
const queued: AgentTask[] = [];
for (const task of snapshot) {
if (task.issue_id !== issueId) continue;
if (task.status === "running") running.push(task);
else if (task.status === "queued" || task.status === "dispatched")
queued.push(task);
// Terminal statuses are intentionally ignored — they belong on the
// issue history, not the live indicator.
}
// Stack heads: prefer running. If 0 running, fall back to queued.
// Each case is visually distinct (running gets shimmer, queued gets
// muted text) so the indicator always offers a face to hover.
const primary = running.length > 0 ? running : queued;
const uniqueAgents = [...new Set(primary.map((t) => t.agent_id))];
return {
runningTasks: running,
queuedTasks: queued,
agentIds: uniqueAgents,
opacity: (running.length > 0 ? "full" : "half") as "full" | "half",
};
}, [snapshot, issueId]);
if (agentIds.length === 0) return null;
const hoverTasks = [...runningTasks, ...queuedTasks];
const isRunning = opacity === "full";
return (
<HoverCard>
<HoverCardTrigger
render={
<span className="inline-flex shrink-0 items-center gap-1" />
}
>
<AgentAvatarStack
agentIds={agentIds}
size={size}
opacity={opacity}
max={3}
/>
<span
className={cn(
"text-[10px] leading-none",
isRunning
? "animate-chat-text-shimmer"
: "text-muted-foreground",
)}
>
{isRunning
? t(($) => $.agent_activity.status_running)
: t(($) => $.agent_activity.status_queued)}
</span>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-72">
<AgentActivityHoverContent tasks={hoverTasks} />
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -68,6 +68,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import type { Issue } from "@multica/core/types";
import { useT } from "../../i18n";
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
import { useIssueViewStore } from "@multica/core/issues/stores/view-store";
import { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip";
// ---------------------------------------------------------------------------
// HoverCheck — shadcn official pattern (PR #6862)
@@ -501,6 +503,21 @@ export function IssuesHeader({
const { t } = useT("issues");
const scope = useIssuesScopeStore((s) => s.scope);
const setScope = useIssuesScopeStore((s) => s.setScope);
// Bind the workspace agents-working chip to the global /issues view
// store. Subscribing here keeps the chip presentational and lets
// /my-issues bind its own store via a sibling header.
const agentRunningFilter = useIssueViewStore((s) => s.agentRunningFilter);
const toggleAgentRunningFilter = useIssueViewStore(
(s) => s.toggleAgentRunningFilter,
);
// Scope the chip to whatever issues this page is currently showing.
// /issues uses the full workspace minus Members/Agents pill filtering;
// passing the visible-issue id set lets the chip count match the list
// length when the filter is on.
const scopedIssueIds = useMemo(
() => new Set(scopedIssues.map((i) => i.id)),
[scopedIssues],
);
const SCOPE_LABEL_KEY: Record<IssuesScope, "all_label" | "members_label" | "agents_label"> = {
all: "all_label",
members: "members_label",
@@ -539,7 +556,19 @@ export function IssuesHeader({
))}
</div>
<IssueDisplayControls scopedIssues={scopedIssues} allowGantt={allowGantt} />
<div className="flex items-center gap-1">
{agentRunningFilter && (
<span className="mr-1 text-xs text-muted-foreground">
{t(($) => $.agent_activity.filter_active_label)}
</span>
)}
<WorkspaceAgentWorkingChip
value={agentRunningFilter}
onToggle={toggleAgentRunningFilter}
scopedIssueIds={scopedIssueIds}
/>
<IssueDisplayControls scopedIssues={scopedIssues} allowGantt={allowGantt} />
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import { useCurrentWorkspace } from "@multica/core/paths";
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueAssigneeGroupsOptions, issueListOptions, childIssueProgressOptions, type AssigneeGroupedIssuesFilter } from "@multica/core/issues/queries";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { PageHeader } from "../../layout/page-header";
@@ -40,8 +41,24 @@ export function IssuesPage() {
const projectFilters = useIssueViewStore((s) => s.projectFilters);
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
const labelFilters = useIssueViewStore((s) => s.labelFilters);
const agentRunningFilter = useIssueViewStore((s) => s.agentRunningFilter);
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
// Derive the set of issue ids that currently have at least one
// `running` agent task. Used by the workspace agents-working filter
// chip. Subscribing the page here (not deep in filter.ts) keeps the
// filter pure and lets the snapshot stay cached at one workspace-
// scoped place — every issue card already subscribes for its own
// indicator, so this is a no-op extra fetch.
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const runningIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const t of snapshot) {
if (t.status === "running" && t.issue_id) ids.add(t.issue_id);
}
return ids;
}, [snapshot]);
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(() => {
const filter: AssigneeGroupedIssuesFilter = {
statuses: statusFilters.length > 0 ? statusFilters : [...BOARD_STATUSES],
@@ -98,8 +115,8 @@ export function IssuesPage() {
const headerIssues = usesAssigneeBoard ? assigneeIssues : scopedIssues;
const issues = useMemo(
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters],
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, agentRunningFilter, runningIssueIds }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, agentRunningFilter, runningIssueIds],
);
// Fetch sub-issue progress from the backend so counts are accurate

View File

@@ -15,6 +15,7 @@ import { PriorityIcon } from "./priority-icon";
import { ProgressRing } from "./progress-ring";
import { IssueActionsContextMenu } from "../actions";
import { LabelChip } from "../../labels/label-chip";
import { IssueAgentActivityIndicator } from "./issue-agent-activity-indicator";
export interface ChildProgress {
done: number;
@@ -82,6 +83,8 @@ export const ListRow = memo(function ListRow({
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{issue.identifier}
</span>
<IssueAgentActivityIndicator issueId={issue.id} />
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate">{issue.title}</span>
{showChildProgress && (

View File

@@ -0,0 +1,153 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@multica/ui/components/ui/button";
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
} from "@multica/ui/components/ui/hover-card";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import type { AgentTask } from "@multica/core/types";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
import { AgentActivityHoverContent } from "../../agents/components/agent-activity-hover-content";
import { useT } from "../../i18n";
interface WorkspaceAgentWorkingChipProps {
// Controlled toggle binding. Different surfaces (Issues page singleton
// hook, My Issues vanilla store) own the underlying state, so the chip
// stays presentational and accepts both forms via plain props.
value: boolean;
onToggle: () => void;
// When set, only running tasks whose issue id is in this set count
// toward the chip — and toward the hover card. Lets the chip stay in
// sync with the page's visible issue scope (e.g. My Issues only shows
// "my" running tasks, not the whole workspace). When omitted, the chip
// shows workspace-wide running agents.
scopedIssueIds?: ReadonlySet<string>;
}
/**
* Filter chip on the issues / my-issues header, sitting to the left of
* the Filter button. Always rendered so the filter toggle never
* disappears mid-flight (a previous design hid the chip when no agents
* were running, which trapped users in an active-but-invisible filter
* state).
*
* Two visual modes:
*
* - Has running agents → avatar stack + count + "working" label,
* wrapped in HoverCard that lists every active task on hover.
* Brand-filled when the filter is on.
*
* - No running agents → "0 working" label, muted when off,
* brand-filled when on. No HoverCard — there is nothing to show;
* the label IS the state.
*
* Click toggles the filter in both modes. The button itself is the
* affordance — no Tooltip wrapping (the popover IS the label when there
* is one, and the label is self-explanatory when there isn't).
*
* `scopedIssueIds` lets a calling header narrow the chip to a subset of
* issues — typically "what's visible on this page right now". My Issues
* uses it so the chip count matches the my-scope list; the global
* /issues page passes the All/Members/Agents-scoped set. Without it the
* chip is workspace-wide.
*/
export function WorkspaceAgentWorkingChip({
value,
onToggle,
scopedIssueIds,
}: WorkspaceAgentWorkingChipProps) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const { runningTasks, agentIds } = useMemo(() => {
const running: AgentTask[] = [];
for (const task of snapshot) {
if (task.status !== "running") continue;
// When scoped, drop running tasks whose issue isn't in the visible
// set — the chip's job is to summarise what the user sees, not
// what's happening elsewhere in the workspace.
if (scopedIssueIds && !scopedIssueIds.has(task.issue_id)) continue;
running.push(task);
}
const unique = [...new Set(running.map((tk) => tk.agent_id))];
return { runningTasks: running, agentIds: unique };
}, [snapshot, scopedIssueIds]);
const hasAgents = agentIds.length > 0;
// Active (brand-filled) class — must explicitly re-pin text and bg in
// every interactive state. Button's `outline` variant ships
// `hover:text-foreground` + `aria-expanded:bg-muted aria-expanded:text-foreground`,
// which would otherwise repaint the brand chip back to neutral on
// hover and while the HoverCard is open.
const activeClass = value
? "border-brand bg-brand text-brand-foreground hover:bg-brand/90 hover:text-brand-foreground aria-expanded:bg-brand aria-expanded:text-brand-foreground"
: hasAgents
? "text-foreground"
: "text-muted-foreground";
const label = t(($) => $.agent_activity.chip_label);
// Idle path: no agents in scope. Still wrap in HoverCard with a
// single-line placeholder so the chip's hover behavior is consistent
// with the active state — an idle chip that does nothing on hover
// reads as broken next to an active one that pops a panel.
if (!hasAgents) {
return (
<HoverCard>
<HoverCardTrigger
render={
<Button
variant="outline"
size="sm"
className={activeClass}
onClick={onToggle}
aria-pressed={value}
>
<span className="tabular-nums">0</span>
<span>{label}</span>
</Button>
}
/>
<HoverCardContent align="end" className="w-auto">
<p className="text-xs text-muted-foreground">
{t(($) => $.agent_activity.empty_hover)}
</p>
</HoverCardContent>
</HoverCard>
);
}
return (
<HoverCard>
<HoverCardTrigger
render={
<Button
variant="outline"
size="sm"
className={activeClass}
onClick={onToggle}
aria-pressed={value}
>
<AgentAvatarStack
agentIds={agentIds}
size={16}
max={3}
opacity="full"
/>
<span className="tabular-nums">{agentIds.length}</span>
<span>{label}</span>
</Button>
}
/>
<HoverCardContent align="end" className="w-72">
<AgentActivityHoverContent tasks={runningTasks} />
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -206,4 +206,46 @@ describe("filterIssues", () => {
expect(result.map((i) => i.id)).not.toContain("L4");
expect(result.map((i) => i.id)).not.toContain("L5");
});
// --- Agent running quick filter ---
it("keeps only running issues when agentRunningFilter is on", () => {
const result = filterIssues(issues, {
...NO_FILTER,
agentRunningFilter: true,
runningIssueIds: new Set(["2", "4"]),
});
expect(result.map((i) => i.id)).toEqual(["2", "4"]);
});
it("hides everything when agentRunningFilter is on but no ids running", () => {
const result = filterIssues(issues, {
...NO_FILTER,
agentRunningFilter: true,
runningIssueIds: new Set(),
});
expect(result).toHaveLength(0);
});
it("ignores runningIssueIds when agentRunningFilter is off", () => {
// The set is irrelevant unless the toggle is true — this guards against
// a future refactor accidentally applying the set as an implicit
// pre-filter when the user hasn't asked for it.
const result = filterIssues(issues, {
...NO_FILTER,
runningIssueIds: new Set(["2"]),
});
expect(result).toHaveLength(4);
});
it("composes agentRunningFilter with other filters (AND semantics)", () => {
const result = filterIssues(issues, {
...NO_FILTER,
statusFilters: ["todo"],
agentRunningFilter: true,
runningIssueIds: new Set(["1", "2"]),
});
// Issue 2 is in_progress (filtered out by status), issue 1 is todo and
// in the running set → only "1" survives.
expect(result.map((i) => i.id)).toEqual(["1"]);
});
});

View File

@@ -10,6 +10,12 @@ export interface IssueFilters {
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
// When `agentRunningFilter` is true, only keep issues whose id is in
// `runningIssueIds`. The set is derived by the caller from
// `agentTaskSnapshot` (one pass over running tasks) so filter.ts stays
// free of any data-fetching dependency.
agentRunningFilter?: boolean;
runningIssueIds?: ReadonlySet<string>;
}
/**
@@ -22,11 +28,18 @@ export interface IssueFilters {
* - When both → show matching assignees + unassigned
*/
export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters } = filters;
const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, agentRunningFilter, runningIssueIds } = filters;
const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee;
const hasProjectFilter = projectFilters.length > 0 || includeNoProject;
// Empty set passed without `agentRunningFilter` is a no-op. When the
// filter is on but the set is missing/empty, hide everything — the
// user opted into "only running" and there is nothing running.
const applyAgentRunning = agentRunningFilter === true;
return issues.filter((issue) => {
if (applyAgentRunning && !(runningIssueIds?.has(issue.id) ?? false))
return false;
if (statusFilters.length > 0 && !statusFilters.includes(issue.status))
return false;

View File

@@ -277,6 +277,15 @@
"expand_tooltip": "Expand",
"collapse_tooltip": "Collapse"
},
"agent_activity": {
"hover_header_one": "{{count}} agent working",
"hover_header_other": "{{count}} agents working",
"status_running": "Working",
"status_queued": "Queued",
"chip_label": "working",
"empty_hover": "No agents currently working",
"filter_active_label": "Viewing only working agents"
},
"agent_live": {
"is_working": "{{name}} is working",
"is_queued": "{{name}} is queued",

View File

@@ -7,6 +7,8 @@
},
"header": {
"scope": {
"all_label": "All",
"all_description": "Assigned to me, created by me, or involving my agents and squads",
"assigned_label": "Assigned",
"assigned_description": "Issues assigned to me",
"created_label": "Created",

View File

@@ -273,6 +273,14 @@
"expand_tooltip": "展开",
"collapse_tooltip": "收起"
},
"agent_activity": {
"hover_header_other": "{{count}} 个智能体正在工作",
"status_running": "正在工作",
"status_queued": "排队中",
"chip_label": "工作中",
"empty_hover": "当前没有智能体在工作",
"filter_active_label": "正在查看工作中的智能体"
},
"agent_live": {
"is_working": "{{name}} 正在处理",
"is_queued": "{{name}} 排队中",

View File

@@ -7,6 +7,8 @@
},
"header": {
"scope": {
"all_label": "全部",
"all_description": "分给我的、我创建的、或我的智能体和小队相关的",
"assigned_label": "已分配",
"assigned_description": "分配给我的 issue",
"created_label": "我创建的",

View File

@@ -50,6 +50,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import type { Issue } from "@multica/core/types";
import { myIssuesViewStore, type MyIssuesScope } from "@multica/core/issues/stores/my-issues-view-store";
import { useT } from "../../i18n";
import { WorkspaceAgentWorkingChip } from "../../issues/components/workspace-agent-working-chip";
// ---------------------------------------------------------------------------
// HoverCheck
@@ -107,7 +108,12 @@ function useIssueCounts(allIssues: Issue[]) {
export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
const { t } = useT("my-issues");
// Pulls the chip-wide "Viewing only working agents" label from the
// shared issues namespace so the copy stays identical with the global
// /issues page header — single source of truth for this filter cue.
const { t: tIssues } = useT("issues");
const SCOPES: { value: MyIssuesScope; label: string; description: string }[] = [
{ value: "all", label: t(($) => $.header.scope.all_label), description: t(($) => $.header.scope.all_description) },
{ value: "assigned", label: t(($) => $.header.scope.assigned_label), description: t(($) => $.header.scope.assigned_description) },
{ value: "created", label: t(($) => $.header.scope.created_label), description: t(($) => $.header.scope.created_description) },
{ value: "agents", label: t(($) => $.header.scope.agents_label), description: t(($) => $.header.scope.agents_description) },
@@ -120,7 +126,16 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
const grouping = useStore(myIssuesViewStore, (s) => s.grouping);
const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
const scope = useStore(myIssuesViewStore, (s) => s.scope);
const agentRunningFilter = useStore(myIssuesViewStore, (s) => s.agentRunningFilter);
const act = myIssuesViewStore.getState();
// Limit the chip to issues actually visible on the My Issues page —
// without this scoping, the chip would report workspace-wide running
// agents (e.g. 3) while the my-scope list only contains one of them,
// and the post-toggle list count would never match the chip number.
const scopedIssueIds = useMemo(
() => new Set(allIssues.map((i) => i.id)),
[allIssues],
);
const counts = useIssueCounts(allIssues);
@@ -162,8 +177,18 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
))}
</div>
{/* Right: filter + display + view toggle */}
{/* Right: agent working chip + filter + display + view toggle */}
<div className="flex items-center gap-1">
{agentRunningFilter && (
<span className="mr-1 text-xs text-muted-foreground">
{tIssues(($) => $.agent_activity.filter_active_label)}
</span>
)}
<WorkspaceAgentWorkingChip
value={agentRunningFilter}
onToggle={act.toggleAgentRunningFilter}
scopedIssueIds={scopedIssueIds}
/>
{/* Filter */}
<DropdownMenu>
<Tooltip>

View File

@@ -20,6 +20,7 @@ import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar
import { useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store";
import { useWorkspaceId } from "@multica/core/hooks";
import { myIssueAssigneeGroupsOptions, myIssueListOptions, childIssueProgressOptions, type AssigneeGroupedIssuesFilter, type MyIssuesFilter } from "@multica/core/issues/queries";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { myIssuesViewStore } from "@multica/core/issues/stores/my-issues-view-store";
import { PageHeader } from "../../layout/page-header";
@@ -36,8 +37,21 @@ export function MyIssuesPage() {
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
const scope = useStore(myIssuesViewStore, (s) => s.scope);
const grouping = useStore(myIssuesViewStore, (s) => s.grouping);
const agentRunningFilter = useStore(myIssuesViewStore, (s) => s.agentRunningFilter);
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
// See issues-page.tsx for the rationale — derive a workspace-wide set
// of issue ids with at least one running task, drive the "agents
// working" quick-filter from it.
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const runningIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const t of snapshot) {
if (t.status === "running" && t.issue_id) ids.add(t.issue_id);
}
return ids;
}, [snapshot]);
// Clear filter state when switching between workspaces (URL-driven).
useClearFiltersOnWorkspaceChange(myIssuesViewStore, wsId);
@@ -59,6 +73,12 @@ export function MyIssuesPage() {
return { creator_id: user.id };
case "agents":
return { involves_user_id: user.id };
case "all":
// "All" is the union of the three single-relation filters above;
// the per-relation user id is plumbed through `userId` to
// myIssue*Options. The filter object stays empty so it carries
// no narrowing of its own.
return {};
default:
return { assignee_id: user.id };
}
@@ -76,9 +96,10 @@ export function MyIssuesPage() {
wsId,
scope,
assigneeGroupFilter,
user?.id,
);
const statusIssuesQuery = useQuery({
...myIssueListOptions(wsId, scope, filter),
...myIssueListOptions(wsId, scope, filter, user?.id),
enabled: !usesAssigneeBoard,
});
const assigneeGroupsQuery = useQuery({
@@ -96,7 +117,7 @@ export function MyIssuesPage() {
? assigneeGroupsQuery.isLoading
: statusIssuesQuery.isLoading;
// Apply status/priority filters from view store
// Apply status/priority/agent-running filters from view store
const issues = useMemo(
() =>
filterIssues(myIssues, {
@@ -108,8 +129,10 @@ export function MyIssuesPage() {
projectFilters: [],
includeNoProject: false,
labelFilters: [],
agentRunningFilter,
runningIssueIds,
}),
[myIssues, statusFilters, priorityFilters],
[myIssues, statusFilters, priorityFilters, agentRunningFilter, runningIssueIds],
);
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));

View File

@@ -949,6 +949,13 @@ func (s *TaskService) StartTask(ctx context.Context, taskID pgtype.UUID) (*db.Ag
slog.Info("task started", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID))
s.captureTaskStarted(ctx, task)
// Tell every connected workspace WS client that this task transitioned
// dispatched → running. Without this, the workspace-wide
// `agentTaskSnapshot` query only refreshes on the 30s staleTime, so any
// UI that distinguishes "queued" from "running" (e.g. the issue-card
// agent activity indicator) lags by up to half a minute on the
// transition users care about most.
s.broadcastTaskEvent(ctx, protocol.EventTaskRunning, task)
return &task, nil
}

View File

@@ -32,6 +32,7 @@ const (
// change" — not "every internal status flip".
EventTaskQueued = "task:queued" // ∅ → queued (enqueue / retry create)
EventTaskDispatch = "task:dispatch" // queued → dispatched (daemon claim)
EventTaskRunning = "task:running" // dispatched → running (daemon started)
EventTaskProgress = "task:progress"
EventTaskCompleted = "task:completed" // running → completed
EventTaskFailed = "task:failed" // running → failed