mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-01 03:19:13 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87f9d0fdd3 | ||
|
|
1c010d25c0 | ||
|
|
48f49d8abc | ||
|
|
c4209ec7c0 | ||
|
|
e57288ba60 |
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import {
|
||||
useGrantAutopilotAccess,
|
||||
useRevokeAutopilotAccess,
|
||||
} from "@multica/core/autopilots/mutations";
|
||||
import type { AutopilotCollaborator } from "@multica/core/types";
|
||||
import { toast } from "sonner";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
PickerEmpty,
|
||||
} from "../../issues/components/pickers/property-picker";
|
||||
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Grant / revoke explicit write access to an autopilot. Members-only, mirroring
|
||||
// the subscriber picker. Creators and workspace admins always have access and
|
||||
// are not listed here — this manages the additional, explicitly-granted set.
|
||||
// Rendered inside the edit dialog's "Manage access" popover; access changes
|
||||
// commit immediately via their own mutations and are independent of the form's
|
||||
// Save action.
|
||||
export function AutopilotAccessManager({
|
||||
autopilotId,
|
||||
collaborators,
|
||||
}: {
|
||||
autopilotId: string;
|
||||
collaborators: AutopilotCollaborator[];
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
const grant = useGrantAutopilotAccess();
|
||||
const revoke = useRevokeAutopilotAccess();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const grantedIds = useMemo(
|
||||
() => new Set(collaborators.map((c) => c.user_id)),
|
||||
[collaborators],
|
||||
);
|
||||
|
||||
const query = filter.trim().toLowerCase();
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
members.filter(
|
||||
(m) =>
|
||||
!grantedIds.has(m.user_id) &&
|
||||
(query === "" ||
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
matchesPinyin(m.name, query)),
|
||||
),
|
||||
[members, grantedIds, query],
|
||||
);
|
||||
|
||||
const handleGrant = async (userId: string) => {
|
||||
try {
|
||||
await grant.mutateAsync({ autopilotId, userId });
|
||||
toast.success(t(($) => $.access.toast_granted));
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || t(($) => $.access.toast_failed));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (userId: string) => {
|
||||
try {
|
||||
await revoke.mutateAsync({ autopilotId, userId });
|
||||
toast.success(t(($) => $.access.toast_revoked));
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || t(($) => $.access.toast_failed));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.access.current_label)}
|
||||
</span>
|
||||
<PropertyPicker
|
||||
open={pickerOpen}
|
||||
onOpenChange={(v) => {
|
||||
setPickerOpen(v);
|
||||
if (!v) setFilter("");
|
||||
}}
|
||||
width="w-64"
|
||||
align="end"
|
||||
searchable
|
||||
searchPlaceholder={t(($) => $.access.search_placeholder)}
|
||||
onSearchChange={setFilter}
|
||||
trigger={
|
||||
<span className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground">
|
||||
<Plus className="size-3" />
|
||||
{t(($) => $.access.add)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{candidates.length === 0 ? (
|
||||
<PickerEmpty />
|
||||
) : (
|
||||
candidates.map((m) => (
|
||||
<PickerItem
|
||||
key={m.user_id}
|
||||
selected={false}
|
||||
onClick={() => {
|
||||
void handleGrant(m.user_id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
|
||||
<span className="truncate">{m.name}</span>
|
||||
</PickerItem>
|
||||
))
|
||||
)}
|
||||
</PropertyPicker>
|
||||
</div>
|
||||
|
||||
{collaborators.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
{t(($) => $.access.empty)}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{collaborators.map((c) => (
|
||||
<li
|
||||
key={c.user_id}
|
||||
className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ActorAvatar actorType="member" actorId={c.user_id} size={20} />
|
||||
<span className="truncate text-sm">
|
||||
{getActorName("member", c.user_id)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRevoke(c.user_id)}
|
||||
disabled={revoke.isPending}
|
||||
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||
aria-label={t(($) => $.access.remove_tooltip)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.access.owner_note)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import {
|
||||
Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil,
|
||||
Ban, ChevronDown, ChevronRight,
|
||||
Webhook, Copy, Check, RotateCw, Users,
|
||||
Webhook, Copy, Check, RotateCw,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries";
|
||||
@@ -62,7 +62,6 @@ import type { AgentTask } from "@multica/core/types/agent";
|
||||
import { ReadonlyContent } from "../../editor";
|
||||
import { TranscriptButton } from "../../common/task-transcript";
|
||||
import { AutopilotDialog } from "./autopilot-dialog";
|
||||
import { ManageAccessDialog } from "./manage-access-dialog";
|
||||
import { WebhookPayloadPreview } from "./webhook-payload-preview";
|
||||
import { WebhookDeliveriesSection } from "./webhook-deliveries-section";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
@@ -635,7 +634,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
|
||||
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [accessDialogOpen, setAccessDialogOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -760,12 +758,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
actions={
|
||||
canWrite ? (
|
||||
<>
|
||||
{canManageAccess && (
|
||||
<Button size="sm" variant="outline" onClick={() => setAccessDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.manage_access)}>
|
||||
<Users className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{t(($) => $.detail.manage_access)}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.edit)}>
|
||||
<Pencil className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{t(($) => $.detail.edit)}</span>
|
||||
@@ -980,14 +972,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
.map((s) => s.user_id) ?? [],
|
||||
}}
|
||||
triggers={triggers}
|
||||
/>
|
||||
)}
|
||||
{accessDialogOpen && (
|
||||
<ManageAccessDialog
|
||||
open={accessDialogOpen}
|
||||
onOpenChange={setAccessDialogOpen}
|
||||
autopilotId={autopilot.id}
|
||||
collaborators={collaborators}
|
||||
canManageAccess={canManageAccess}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Minimize2,
|
||||
Play,
|
||||
Rocket,
|
||||
Users,
|
||||
Webhook,
|
||||
X as XIcon,
|
||||
Zap,
|
||||
@@ -27,6 +28,14 @@ import {
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -51,6 +60,7 @@ import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
|
||||
import { api } from "@multica/core/api";
|
||||
import type {
|
||||
AutopilotAssigneeType,
|
||||
AutopilotCollaborator,
|
||||
AutopilotExecutionMode,
|
||||
AutopilotTrigger,
|
||||
} from "@multica/core/types";
|
||||
@@ -60,6 +70,7 @@ import { ProjectPicker } from "../../projects/components/project-picker";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { AgentPicker, type AssigneeSelection } from "./pickers/agent-picker";
|
||||
import { SubscriberMultiSelect } from "./subscriber-multi-select";
|
||||
import { AutopilotAccessManager } from "./autopilot-access-manager";
|
||||
import {
|
||||
getDefaultTriggerConfig,
|
||||
getLocalTimezone,
|
||||
@@ -102,6 +113,8 @@ export type AutopilotDialogProps =
|
||||
autopilotId: string;
|
||||
initial: AutopilotInitial;
|
||||
triggers: AutopilotTrigger[];
|
||||
collaborators: AutopilotCollaborator[];
|
||||
canManageAccess: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -555,6 +568,29 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isCreate && props.canManageAccess && (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-xs text-muted-foreground opacity-90 transition-all hover:bg-accent/60 hover:text-foreground hover:opacity-100 cursor-pointer">
|
||||
<Users className="size-3.5" />
|
||||
<span>{t(($) => $.access.title)}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={6} keepMounted className="w-80">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>{t(($) => $.access.title)}</PopoverTitle>
|
||||
<PopoverDescription className="text-xs">
|
||||
{t(($) => $.access.description)}
|
||||
</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<AutopilotAccessManager
|
||||
autopilotId={props.autopilotId}
|
||||
collaborators={props.collaborators}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="mx-0.5 h-4 w-px bg-border" />
|
||||
</>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import {
|
||||
useGrantAutopilotAccess,
|
||||
useRevokeAutopilotAccess,
|
||||
} from "@multica/core/autopilots/mutations";
|
||||
import type { AutopilotCollaborator } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
PickerEmpty,
|
||||
} from "../../issues/components/pickers/property-picker";
|
||||
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Grant / revoke explicit write access to an autopilot. Members-only, mirroring
|
||||
// the subscriber picker. Creators and workspace admins always have access and
|
||||
// are not listed here — this manages the additional, explicitly-granted set.
|
||||
export function ManageAccessDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
autopilotId,
|
||||
collaborators,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
autopilotId: string;
|
||||
collaborators: AutopilotCollaborator[];
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
const grant = useGrantAutopilotAccess();
|
||||
const revoke = useRevokeAutopilotAccess();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const grantedIds = useMemo(
|
||||
() => new Set(collaborators.map((c) => c.user_id)),
|
||||
[collaborators],
|
||||
);
|
||||
|
||||
const query = filter.trim().toLowerCase();
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
members.filter(
|
||||
(m) =>
|
||||
!grantedIds.has(m.user_id) &&
|
||||
(query === "" ||
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
matchesPinyin(m.name, query)),
|
||||
),
|
||||
[members, grantedIds, query],
|
||||
);
|
||||
|
||||
const handleGrant = async (userId: string) => {
|
||||
try {
|
||||
await grant.mutateAsync({ autopilotId, userId });
|
||||
toast.success(t(($) => $.access.toast_granted));
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || t(($) => $.access.toast_failed));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (userId: string) => {
|
||||
try {
|
||||
await revoke.mutateAsync({ autopilotId, userId });
|
||||
toast.success(t(($) => $.access.toast_revoked));
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || t(($) => $.access.toast_failed));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogTitle>{t(($) => $.access.title)}</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(($) => $.access.description)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.access.current_label)}
|
||||
</span>
|
||||
<PropertyPicker
|
||||
open={pickerOpen}
|
||||
onOpenChange={(v) => {
|
||||
setPickerOpen(v);
|
||||
if (!v) setFilter("");
|
||||
}}
|
||||
width="w-64"
|
||||
align="start"
|
||||
searchable
|
||||
searchPlaceholder={t(($) => $.access.search_placeholder)}
|
||||
onSearchChange={setFilter}
|
||||
trigger={
|
||||
<span className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground">
|
||||
<Plus className="size-3" />
|
||||
{t(($) => $.access.add)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{candidates.length === 0 ? (
|
||||
<PickerEmpty />
|
||||
) : (
|
||||
candidates.map((m) => (
|
||||
<PickerItem
|
||||
key={m.user_id}
|
||||
selected={false}
|
||||
onClick={() => {
|
||||
void handleGrant(m.user_id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
|
||||
<span className="truncate">{m.name}</span>
|
||||
</PickerItem>
|
||||
))
|
||||
)}
|
||||
</PropertyPicker>
|
||||
</div>
|
||||
|
||||
{collaborators.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
{t(($) => $.access.empty)}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{collaborators.map((c) => (
|
||||
<li
|
||||
key={c.user_id}
|
||||
className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ActorAvatar actorType="member" actorId={c.user_id} size={20} />
|
||||
<span className="truncate text-sm">
|
||||
{getActorName("member", c.user_id)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRevoke(c.user_id)}
|
||||
disabled={revoke.isPending}
|
||||
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||
aria-label={t(($) => $.access.remove_tooltip)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.access.owner_note)}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentTask } from "@multica/core/types";
|
||||
import { renderWithI18n } from "../../test/i18n";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
snapshot: [] as unknown[],
|
||||
// Captures the agent ids handed to the avatar stack so a test can assert
|
||||
// the stack still reflects distinct agents even when the count counts issues.
|
||||
avatarAgentIds: undefined as string[] | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/agents", () => ({
|
||||
agentTaskSnapshotOptions: (wsId: string) => ({
|
||||
queryKey: ["agents", "task-snapshot", wsId],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/components/agent-avatar-stack", () => ({
|
||||
AgentAvatarStack: ({ agentIds }: { agentIds: string[] }) => {
|
||||
mockState.avatarAgentIds = agentIds;
|
||||
return <div data-testid="agent-avatar-stack">{agentIds.length}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/components/agent-activity-hover-content", () => ({
|
||||
AgentActivityHoverContent: ({ tasks }: { tasks: AgentTask[] }) => (
|
||||
<div data-testid="activity-hover">{tasks.length}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (opts: { queryKey?: readonly unknown[] }) => {
|
||||
if (opts.queryKey?.[1] === "task-snapshot") {
|
||||
return { data: mockState.snapshot };
|
||||
}
|
||||
return { data: undefined };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip";
|
||||
|
||||
function makeTask(overrides: Partial<AgentTask>): AgentTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: "issue-1",
|
||||
status: "running",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: "2026-06-08T08:00:00Z",
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-06-08T08:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockState.snapshot = [];
|
||||
mockState.avatarAgentIds = undefined;
|
||||
});
|
||||
|
||||
describe("WorkspaceAgentWorkingChip", () => {
|
||||
it("counts distinct active issues, not running agents", () => {
|
||||
// Two agents working the SAME issue: the count is about issues, so it
|
||||
// must read "1", not "2" (the old unique-agent behavior). MUL-3875.
|
||||
mockState.snapshot = [
|
||||
makeTask({ id: "t-1", agent_id: "agent-1", issue_id: "issue-1" }),
|
||||
makeTask({ id: "t-2", agent_id: "agent-2", issue_id: "issue-1" }),
|
||||
];
|
||||
|
||||
renderWithI18n(
|
||||
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /working/i }),
|
||||
).toHaveTextContent("1");
|
||||
// The avatar stack still shows both distinct agents behind that work.
|
||||
expect(mockState.avatarAgentIds).toEqual(["agent-1", "agent-2"]);
|
||||
});
|
||||
|
||||
it("counts each distinct issue once when agents span several issues", () => {
|
||||
mockState.snapshot = [
|
||||
makeTask({ id: "t-1", agent_id: "agent-1", issue_id: "issue-1" }),
|
||||
makeTask({ id: "t-2", agent_id: "agent-2", issue_id: "issue-2" }),
|
||||
makeTask({ id: "t-3", agent_id: "agent-1", issue_id: "issue-3" }),
|
||||
];
|
||||
|
||||
renderWithI18n(
|
||||
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /working/i }),
|
||||
).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
it("ignores non-running tasks and respects scopedIssueIds", () => {
|
||||
mockState.snapshot = [
|
||||
makeTask({ id: "t-1", issue_id: "issue-1", status: "running" }),
|
||||
makeTask({ id: "t-2", issue_id: "issue-2", status: "queued" }),
|
||||
makeTask({ id: "t-3", issue_id: "issue-3", status: "running" }),
|
||||
];
|
||||
|
||||
renderWithI18n(
|
||||
<WorkspaceAgentWorkingChip
|
||||
value={false}
|
||||
onToggle={() => {}}
|
||||
scopedIssueIds={new Set(["issue-1"])}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only the running task within scope counts → "1".
|
||||
expect(
|
||||
screen.getByRole("button", { name: /working/i }),
|
||||
).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("shows 0 when no agents are running", () => {
|
||||
mockState.snapshot = [];
|
||||
|
||||
renderWithI18n(
|
||||
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /working/i }),
|
||||
).toHaveTextContent("0");
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ export function WorkspaceAgentWorkingChip({
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
const { runningTasks, agentIds } = useMemo(() => {
|
||||
const { runningTasks, agentIds, issueIds } = useMemo(() => {
|
||||
const running: AgentTask[] = [];
|
||||
for (const task of snapshot) {
|
||||
if (task.status !== "running") continue;
|
||||
@@ -75,11 +75,21 @@ export function WorkspaceAgentWorkingChip({
|
||||
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 };
|
||||
// The count tracks active *issues*, not active agents: several agents
|
||||
// can work the same issue at once, and the chip answers "how many
|
||||
// issues are agents working on right now?" (its filter narrows the
|
||||
// list to exactly those issues). The avatar stack still shows the
|
||||
// distinct agents behind that work.
|
||||
const uniqueIssues = [...new Set(running.map((tk) => tk.issue_id))];
|
||||
const uniqueAgents = [...new Set(running.map((tk) => tk.agent_id))];
|
||||
return {
|
||||
runningTasks: running,
|
||||
agentIds: uniqueAgents,
|
||||
issueIds: uniqueIssues,
|
||||
};
|
||||
}, [snapshot, scopedIssueIds]);
|
||||
|
||||
const hasAgents = agentIds.length > 0;
|
||||
const hasAgents = issueIds.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`,
|
||||
@@ -140,7 +150,7 @@ export function WorkspaceAgentWorkingChip({
|
||||
max={3}
|
||||
opacity="full"
|
||||
/>
|
||||
<span className="tabular-nums">{agentIds.length}</span>
|
||||
<span className="tabular-nums">{issueIds.length}</span>
|
||||
<span className="hidden md:inline">{label}</span>
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"pause_aria": "Pause autopilot",
|
||||
"activate_aria": "Activate autopilot",
|
||||
"edit": "Edit",
|
||||
"manage_access": "Manage access",
|
||||
"run_now": "Run now",
|
||||
"running": "Running...",
|
||||
"toast_triggered": "Autopilot triggered",
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"pause_aria": "オートパイロットを一時停止",
|
||||
"activate_aria": "オートパイロットを有効化",
|
||||
"edit": "編集",
|
||||
"manage_access": "アクセス管理",
|
||||
"run_now": "今すぐ実行",
|
||||
"running": "実行中...",
|
||||
"toast_triggered": "オートパイロットを実行しました",
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"pause_aria": "오토파일럿 일시 중지",
|
||||
"activate_aria": "오토파일럿 활성화",
|
||||
"edit": "수정",
|
||||
"manage_access": "접근 권한 관리",
|
||||
"run_now": "지금 실행",
|
||||
"running": "실행 중...",
|
||||
"toast_triggered": "오토파일럿을 실행했습니다",
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"pause_aria": "暂停自动化",
|
||||
"activate_aria": "启用自动化",
|
||||
"edit": "编辑",
|
||||
"manage_access": "管理访问",
|
||||
"run_now": "立即运行",
|
||||
"running": "运行中...",
|
||||
"toast_triggered": "已触发自动化",
|
||||
|
||||
@@ -2342,6 +2342,24 @@ func (h *Handler) ReportTaskUsage(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
h.TaskService.CaptureTaskUsage(r.Context(), task, provider, u.Model, u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
|
||||
|
||||
// Surface prompt-cache effectiveness per run so cache hit rates are
|
||||
// observable in logs, not just queryable from runtime_usage. The ratio
|
||||
// is cached input over total input-side tokens; a persistently low
|
||||
// value flags a prompt prefix that is not being reused across runs
|
||||
// (e.g. volatile values poisoning the cacheable prefix). MUL-3887.
|
||||
if totalInput := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens; totalInput > 0 {
|
||||
slog.Info("task prompt-cache usage",
|
||||
"task_id", taskID,
|
||||
"provider", provider,
|
||||
"model", u.Model,
|
||||
"input_tokens", u.InputTokens,
|
||||
"output_tokens", u.OutputTokens,
|
||||
"cache_read_tokens", u.CacheReadTokens,
|
||||
"cache_write_tokens", u.CacheWriteTokens,
|
||||
"cache_read_ratio", float64(u.CacheReadTokens)/float64(totalInput),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
|
||||
Reference in New Issue
Block a user