diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index f27d895d3..f1d96bc24 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -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 { + 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(); + 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 { + 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(); + 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({ 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, + }), }); } diff --git a/packages/core/issues/stores/my-issues-view-store.ts b/packages/core/issues/stores/my-issues-view-store.ts index 766f41388..e197af79d 100644 --- a/packages/core/issues/stores/my-issues-view-store.ts +++ b/packages/core/issues/stores/my-issues-view-store.ts @@ -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; diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index 65d99b112..a94117d8d 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -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["setState"]): Issue projectFilters: [], includeNoProject: false, labelFilters: [], + agentRunningFilter: false, sortBy: "position", sortDirection: "asc", cardProperties: { @@ -180,6 +188,8 @@ export const viewStoreSlice = (set: StoreApi["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["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, diff --git a/packages/views/agents/components/agent-activity-hover-content.tsx b/packages/views/agents/components/agent-activity-hover-content.tsx new file mode 100644 index 000000000..3036d9c04 --- /dev/null +++ b/packages/views/agents/components/agent-activity-hover-content.tsx @@ -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 ( +
+
+ {t(($) => $.agent_activity.hover_header, { count: tasks.length })} +
+
+ {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 ( +
+ + + {getActorName("agent", task.agent_id)} + + + + + {isRunning + ? t(($) => $.agent_activity.status_running) + : t(($) => $.agent_activity.status_queued)} + + + {formatDuration(startedFrom, now)} + + +
+ ); + })} +
+
+ ); +} + +function runtimeFrom( + id: string | undefined, + byId: Map, +): 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); +} diff --git a/packages/views/agents/components/agent-avatar-stack.tsx b/packages/views/agents/components/agent-avatar-stack.tsx new file mode 100644 index 000000000..3bbe30fc8 --- /dev/null +++ b/packages/views/agents/components/agent-avatar-stack.tsx @@ -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 ( + + {visible.map((id, i) => ( + + + + ))} + {overflow > 0 && ( + + +{overflow} + + )} + + ); +} diff --git a/packages/views/issues/components/board-card.tsx b/packages/views/issues/components/board-card.tsx index e8630151f..0c04b6a69 100644 --- a/packages/views/issues/components/board-card.tsx +++ b/packages/views/issues/components/board-card.tsx @@ -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 (
- {/* Row 1: Identifier */} -

{issue.identifier}

+ {/* Row 1: Identifier + agent activity indicator (top-right) */} +
+

{issue.identifier}

+ +
{/* Row 2: Title */}

diff --git a/packages/views/issues/components/issue-agent-activity-indicator.tsx b/packages/views/issues/components/issue-agent-activity-indicator.tsx new file mode 100644 index 000000000..a2a3a6e35 --- /dev/null +++ b/packages/views/issues/components/issue-agent-activity-indicator.tsx @@ -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 ( + + + } + > + + + {isRunning + ? t(($) => $.agent_activity.status_running) + : t(($) => $.agent_activity.status_queued)} + + + + + + + ); +} diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index 6882a2e49..440ad4a0b 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -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 = { all: "all_label", members: "members_label", @@ -539,7 +556,19 @@ export function IssuesHeader({ ))}

- +
+ {agentRunningFilter && ( + + {t(($) => $.agent_activity.filter_active_label)} + + )} + + +
); } diff --git a/packages/views/issues/components/issues-page.tsx b/packages/views/issues/components/issues-page.tsx index f93e06792..93adacc98 100644 --- a/packages/views/issues/components/issues-page.tsx +++ b/packages/views/issues/components/issues-page.tsx @@ -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(); + for (const t of snapshot) { + if (t.status === "running" && t.issue_id) ids.add(t.issue_id); + } + return ids; + }, [snapshot]); + const assigneeGroupFilter = useMemo(() => { 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 diff --git a/packages/views/issues/components/list-row.tsx b/packages/views/issues/components/list-row.tsx index 8f1bd1966..2fcd75590 100644 --- a/packages/views/issues/components/list-row.tsx +++ b/packages/views/issues/components/list-row.tsx @@ -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({ {issue.identifier} + + {issue.title} {showChildProgress && ( diff --git a/packages/views/issues/components/workspace-agent-working-chip.tsx b/packages/views/issues/components/workspace-agent-working-chip.tsx new file mode 100644 index 000000000..5381e1304 --- /dev/null +++ b/packages/views/issues/components/workspace-agent-working-chip.tsx @@ -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; +} + +/** + * 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 ( + + + 0 + {label} + + } + /> + +

+ {t(($) => $.agent_activity.empty_hover)} +

+
+
+ ); + } + + return ( + + + + {agentIds.length} + {label} + + } + /> + + + + + ); +} diff --git a/packages/views/issues/utils/filter.test.ts b/packages/views/issues/utils/filter.test.ts index 4f09b6b25..ecb11a55e 100644 --- a/packages/views/issues/utils/filter.test.ts +++ b/packages/views/issues/utils/filter.test.ts @@ -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"]); + }); }); diff --git a/packages/views/issues/utils/filter.ts b/packages/views/issues/utils/filter.ts index 5d0308122..86694bfd2 100644 --- a/packages/views/issues/utils/filter.ts +++ b/packages/views/issues/utils/filter.ts @@ -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; } /** @@ -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; diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index eafeeaa55..eb50d20ea 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -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", diff --git a/packages/views/locales/en/my-issues.json b/packages/views/locales/en/my-issues.json index ae162bc30..7abc2300d 100644 --- a/packages/views/locales/en/my-issues.json +++ b/packages/views/locales/en/my-issues.json @@ -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", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 67dae78ac..bc25afcb1 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -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}} 排队中", diff --git a/packages/views/locales/zh-Hans/my-issues.json b/packages/views/locales/zh-Hans/my-issues.json index 6a7557dd2..8303259ab 100644 --- a/packages/views/locales/zh-Hans/my-issues.json +++ b/packages/views/locales/zh-Hans/my-issues.json @@ -7,6 +7,8 @@ }, "header": { "scope": { + "all_label": "全部", + "all_description": "分给我的、我创建的、或我的智能体和小队相关的", "assigned_label": "已分配", "assigned_description": "分配给我的 issue", "created_label": "我创建的", diff --git a/packages/views/my-issues/components/my-issues-header.tsx b/packages/views/my-issues/components/my-issues-header.tsx index ce03c2bb3..8c3e011d0 100644 --- a/packages/views/my-issues/components/my-issues-header.tsx +++ b/packages/views/my-issues/components/my-issues-header.tsx @@ -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[] }) { ))} - {/* Right: filter + display + view toggle */} + {/* Right: agent working chip + filter + display + view toggle */}
+ {agentRunningFilter && ( + + {tIssues(($) => $.agent_activity.filter_active_label)} + + )} + {/* Filter */} diff --git a/packages/views/my-issues/components/my-issues-page.tsx b/packages/views/my-issues/components/my-issues-page.tsx index 8bc0535ad..b21c0d89a 100644 --- a/packages/views/my-issues/components/my-issues-page.tsx +++ b/packages/views/my-issues/components/my-issues-page.tsx @@ -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(); + 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)); diff --git a/server/internal/service/task.go b/server/internal/service/task.go index f703e4aa8..321c163b9 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -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 } diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index c34bb914f..dd736ba68 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -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