mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 23:49:22 +02:00
Compare commits
3 Commits
fix/issue-
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f705b270e | ||
|
|
64aec1d58f | ||
|
|
e5db641fd8 |
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
92
packages/views/agents/components/agent-avatar-stack.tsx
Normal file
92
packages/views/agents/components/agent-avatar-stack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}} 排队中",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
},
|
||||
"header": {
|
||||
"scope": {
|
||||
"all_label": "全部",
|
||||
"all_description": "分给我的、我创建的、或我的智能体和小队相关的",
|
||||
"assigned_label": "已分配",
|
||||
"assigned_description": "分配给我的 issue",
|
||||
"created_label": "我创建的",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user