Compare commits

...

25 Commits

Author SHA1 Message Date
Jiayuan
58a8655b43 fix(web): remember last selected workspace after re-login
Stop clearing multica_workspace_id from localStorage on logout so it
persists as a preference hint. On fresh login, pass the stored ID to
hydrateWorkspace so the user returns to their last workspace instead
of always landing on the first one.
2026-04-06 00:59:35 +08:00
Jiayuan Zhang
9aee403ff9 Merge pull request #405 from multica-ai/agent/lambda/8bf9407f
fix(comment): allow @mention of assigned agent on done/cancelled issues
2026-04-04 13:31:07 +08:00
Jiayuan Zhang
7883fe7bd7 Merge pull request #406 from multica-ai/fix/setup-worktree-env-overwrite
fix: make setup-worktree preserve existing .env.worktree
2026-04-04 13:26:17 +08:00
Jiayuan
cbfb7d58b6 fix: make setup-worktree preserve existing .env.worktree
setup-worktree was using FORCE=1 to unconditionally regenerate
.env.worktree, which overwrites any manual edits (e.g. switching
to the main DB). Now it only generates the file if it doesn't exist.
2026-04-04 13:23:14 +08:00
Jiayuan
2832a06fe3 fix(comment): allow @mention of assigned agent on done/cancelled issues
The assignee was unconditionally skipped in the mention path, assuming
on_comment would handle it. But on_comment is suppressed for terminal
statuses (done/cancelled), so an explicit @mention of the assignee had
no effect. Now only skip the assignee dedup when on_comment will
actually fire (non-terminal status).
2026-04-04 13:14:09 +08:00
Jiayuan Zhang
451715f5a1 fix(web): prevent Archive Agent button text from wrapping to two lines (#404)
Add w-auto class to DropdownMenuContent on agent detail panel, matching
the pattern used by other dropdowns in the codebase. The default
w-(--anchor-width) was constraining the popup to the icon button width.
2026-04-04 12:59:25 +08:00
Jiayuan Zhang
fdf594155c Merge pull request #396 from multica-ai/feat/comment-list-pagination
feat(comments): add pagination to comment list API and CLI
2026-04-04 01:07:22 +08:00
Jiayuan
c39470a53f fix(comments): address code review feedback on pagination
1. Update CLAUDE.md template to document --limit, --offset, --since
   params and guide agents to use pagination when comments are large
2. Add GetJSONWithHeaders to API client; CLI now prints "Showing X of Y
   comments" to stderr when paginating
3. Cap --since without --limit at 500 server-side to prevent unbounded
   result sets
2026-04-04 01:01:48 +08:00
Jiayuan Zhang
e5dfb34a2a Merge pull request #398 from multica-ai/agent/lambda/df68aca8
fix(inbox): archive at issue level instead of event level
2026-04-04 00:30:04 +08:00
Jiayuan
58549975e0 fix(inbox): archive all items for the same issue instead of just one
The inbox UI deduplicates items by issue_id (showing only the latest
notification per issue). Previously, clicking archive only archived the
single visible item, so older items for the same issue would reappear.

Now archiving operates at the issue level — both the backend and frontend
archive all inbox items sharing the same issue_id.
2026-04-04 00:18:14 +08:00
Jiayuan
0bbc6bc1c5 feat(comments): add pagination support to comment list API and CLI
Add --limit, --offset, and --since flags to `multica issue comment list`
to prevent context window overflow when issues have many comments.

The API endpoint now accepts limit, offset, and since (RFC3339) query
parameters. When paginating, the response includes an X-Total-Count
header with the total number of comments.
2026-04-03 23:53:00 +08:00
Bohan Jiang
beeb8bc107 Merge pull request #361 from JimmyPang02/fix/issue-mention-on-comment
fix: issue mentions should not suppress on_comment trigger
2026-04-03 18:42:16 +08:00
Naiyuan Qing
5548d60dbb Revert "fix(issues): prevent sticky mini bar oscillation with height placeholder"
This reverts commit 9fb25f4543.
2026-04-03 18:35:52 +08:00
Naiyuan Qing
9fb25f4543 fix(issues): prevent sticky mini bar oscillation with height placeholder
When the agent live card collapses to sticky mode, its height drops from
~320px to ~40px. This layout shift caused content below to jump up,
re-triggering IntersectionObserver and creating an infinite loop.

Fix: capture the card's expanded height before collapsing, then set
minHeight on a wrapper div to preserve the space. Content below stays
put, sentinel stays out of view, no oscillation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:30:09 +08:00
Bohan Jiang
33140d4c5a docs(web): add v0.1.6 changelog entry (#391)
* docs(web): add v0.1.5 changelog entry for 2026-04-02

* docs(web): add v0.1.6 changelog entry for 2026-04-03
2026-04-03 16:39:56 +08:00
Jiayuan Zhang
9b8cc0870b Merge pull request #388 from multica-ai/agent/lambda/1286014b
feat(cli): add agent, skill, and runtime management commands
2026-04-03 16:14:37 +08:00
Naiyuan Qing
ce40b66c60 Merge pull request #390 from multica-ai/feat/agent-live-card-sticky-minibar
feat(issues): sticky mini bar for agent live card + toast icon colors
2026-04-03 16:09:27 +08:00
Naiyuan Qing
56b49cb2a6 feat(issues): use ActorAvatar in agent live card header
Replace hand-rolled Bot icon circle with ActorAvatar component so
agent custom avatars display correctly, consistent with comment cards
and other agent-rendered UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:08:12 +08:00
Naiyuan Qing
4353340ea6 feat(issues): sticky mini bar for agent live card + toast icon colors
Agent live card now uses the sentinel pattern to detect when it scrolls
out of view. When stuck, it collapses to a compact header bar with brand
styling and backdrop blur, with a ChevronUp button to scroll back.
When scrolled back into view, the card seamlessly expands to full view.

Also adds semantic colors to Sonner toast icons (success/info/warning/
error/loading) and fixes icon-to-text alignment in toasts globally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:05:35 +08:00
Jiayuan
91cbf32fd1 fix(cli): address code review feedback for agent/skill/runtime commands
- Add --config flag to skill create/update (accepts JSON string)
- Add --runtime-config flag to agent create/update (accepts JSON string)
- Add --yes flag to skill delete with confirmation prompt
- Improve agent skills set error message (guide users to --skill-ids '')
- Validate --days range (1-365) in runtime usage
- Include last known status in ping/update timeout errors
2026-04-03 16:00:12 +08:00
Jiayuan
10b482fac2 feat(cli): add agent, skill, and runtime management commands
Expand CLI coverage for agent/skill/runtime APIs that previously had no
CLI wrappers despite having fully implemented backend endpoints.

Agent commands (was: only list):
- agent get/create/update/archive/restore/tasks
- agent skills list/set
- agent list --include-archived

Skill commands (new, was: 0% coverage):
- skill list/get/create/update/delete/import
- skill files list/upsert/delete

Runtime commands (new, was: 0% coverage):
- runtime list/usage/activity/ping/update
- ping and update support --wait for polling
2026-04-03 15:41:17 +08:00
Jiayuan Zhang
0024208354 Merge pull request #387 from multica-ai/fix/filter-archived-agents-from-dropdowns
fix(web): filter archived agents from dropdown selectors
2026-04-03 15:41:01 +08:00
Bohan Jiang
32a3a3543d docs(web): add v0.1.5 changelog entry for 2026-04-02 (#386) 2026-04-03 15:40:15 +08:00
Jiayuan
e314badf18 fix(web): filter archived agents from all dropdown selectors
Add `!a.archived_at` check to agent filters in create-issue modal,
issues-header filter panel, issue-detail assignee dropdown, and
issue-detail subscriber list.

assignee-picker and mention-suggestion already filter correctly.
2026-04-03 15:39:27 +08:00
Jimmy Peng
6799458807 fix: issue mentions should not suppress on_comment trigger 2026-04-03 01:12:38 +08:00
29 changed files with 1872 additions and 96 deletions

View File

@@ -98,8 +98,12 @@ check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree:
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
else \
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
fi
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree:

View File

@@ -153,7 +153,8 @@ function LoginPageContent() {
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError(

View File

@@ -1437,7 +1437,7 @@ function AgentDetail({
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem
className="text-destructive"
onClick={() => setConfirmArchive(true)}

View File

@@ -30,3 +30,12 @@
background-color: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
/* Sonner toast: align icon to first line of text, not vertically centered */
[data-sonner-toast] {
align-items: flex-start !important;
}
[data-sonner-toast] [data-icon] {
margin-top: 2.5px;
}

View File

@@ -13,19 +13,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
<CircleCheckIcon className="size-4 text-success" />
),
info: (
<InfoIcon className="size-4" />
<InfoIcon className="size-4 text-info" />
),
warning: (
<TriangleAlertIcon className="size-4" />
<TriangleAlertIcon className="size-4 text-warning" />
),
error: (
<OctagonXIcon className="size-4" />
<OctagonXIcon className="size-4 text-destructive" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
<Loader2Icon className="size-4 animate-spin text-brand" />
),
}}
style={

View File

@@ -36,7 +36,6 @@ export const useAuthStore = create<AuthState>((set) => ({
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
set({ user: null, isLoading: false });
}
},
@@ -56,7 +55,6 @@ export const useAuthStore = create<AuthState>((set) => ({
logout: () => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
clearLoggedInCookie();

View File

@@ -90,9 +90,17 @@ export const useInboxStore = create<InboxState>((set, get) => ({
items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)),
})),
archive: (id) =>
set((s) => ({
items: s.items.map((i) => (i.id === id ? { ...i, archived: true } : i)),
})),
set((s) => {
const target = s.items.find((i) => i.id === id);
const issueId = target?.issue_id;
return {
items: s.items.map((i) =>
i.id === id || (issueId && i.issue_id === issueId)
? { ...i, archived: true }
: i,
),
};
}),
markAllRead: () =>
set((s) => ({
items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)),

View File

@@ -1,13 +1,14 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
import { Bot, ChevronRight, ChevronUp, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
import { api } from "@/shared/api";
import { useWSEvent } from "@/features/realtime";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
import type { AgentTask } from "@/shared/types/agent";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useActorName } from "@/features/workspace";
import { redactSecrets } from "../utils/redact";
@@ -99,16 +100,20 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
interface AgentLiveCardProps {
issueId: string;
agentName?: string;
/** Scroll container ref — needed for sticky sentinel detection. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
}
export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) {
const { getActorName } = useActorName();
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
const [items, setItems] = useState<TimelineItem[]>([]);
const [elapsed, setElapsed] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const [cancelling, setCancelling] = useState(false);
const [isStuck, setIsStuck] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const seenSeqs = useRef(new Set<string>());
// Check for active task on mount
@@ -215,12 +220,36 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
// Elapsed time
useEffect(() => {
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
const ref = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(ref));
const interval = setInterval(() => setElapsed(formatElapsed(ref)), 1000);
const startRef = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(startRef));
const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000);
return () => clearInterval(interval);
}, [activeTask?.started_at, activeTask?.dispatched_at]);
// Sentinel pattern: detect when the card is scrolled past and becomes "stuck"
useEffect(() => {
const sentinel = sentinelRef.current;
const root = scrollContainerRef?.current;
if (!sentinel || !root || !activeTask) {
setIsStuck(false);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]) setIsStuck(!entries[0].isIntersecting);
},
{ root, threshold: 0, rootMargin: "-40px 0px 0px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [scrollContainerRef, activeTask]);
const scrollToCard = useCallback(() => {
sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
// Auto-scroll
useEffect(() => {
if (autoScroll && scrollRef.current) {
@@ -248,67 +277,104 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
if (!activeTask) return null;
const toolCount = items.filter((i) => i.type === "tool_use").length;
const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent";
return (
<div className="rounded-lg border border-info/20 bg-info/5">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center justify-center h-5 w-5 rounded-full bg-info/10 text-info shrink-0">
<Bot className="h-3 w-3" />
</div>
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
<span className="truncate">{(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working</span>
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
<>
{/* Sentinel — zero-height element that IntersectionObserver watches */}
<div ref={sentinelRef} className="mt-4 h-0 pointer-events-none" aria-hidden />
<div
className={cn(
"rounded-lg border transition-all duration-200",
isStuck
? "sticky top-4 z-10 shadow-md border-brand/30 bg-brand/10 backdrop-blur-md"
: "border-info/20 bg-info/5",
)}
<button
onClick={handleCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
title="Stop agent"
>
{cancelling ? (
<Loader2 className="h-3 w-3 animate-spin" />
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
{activeTask.agent_id ? (
<ActorAvatar actorType="agent" actorId={activeTask.agent_id} size={20} />
) : (
<Square className="h-3 w-3" />
<div className={cn(
"flex items-center justify-center h-5 w-5 rounded-full shrink-0",
isStuck ? "bg-brand/15 text-brand" : "bg-info/10 text-info",
)}>
<Bot className="h-3 w-3" />
</div>
)}
<span>Stop</span>
</button>
</div>
{/* Timeline content */}
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{!autoScroll && (
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className={cn("h-3 w-3 animate-spin shrink-0", isStuck ? "text-brand" : "text-info")} />
<span className="truncate">{name} is working</span>
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{!isStuck && toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
)}
{isStuck ? (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
onClick={scrollToCard}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
title="Scroll to live card"
>
<ArrowDown className="h-3 w-3" />
Latest
<ChevronUp className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
title="Stop agent"
>
{cancelling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Square className="h-3 w-3" />
)}
<span>Stop</span>
</button>
)}
</div>
)}
</div>
{/* Timeline content — collapses when stuck */}
<div
className={cn(
"overflow-hidden transition-all duration-200",
isStuck ? "max-h-0 opacity-0" : "max-h-[20rem] opacity-100",
)}
>
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{!autoScroll && (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
>
<ArrowDown className="h-3 w-3" />
Latest
</button>
)}
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -513,7 +513,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
{agents.filter((a) => !a.archived_at && canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
@@ -742,9 +742,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
})}
</CommandGroup>
)}
{agents.length > 0 && (
{agents.filter((a) => !a.archived_at).length > 0 && (
<CommandGroup heading="Agents">
{agents.map((a) => {
{agents.filter((a) => !a.archived_at).map((a) => {
const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id);
const isSubbed = !!sub;
return (
@@ -771,12 +771,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
{/* Agent live output */}
<div className="mt-4">
<AgentLiveCard
issueId={id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
/>
</div>
<AgentLiveCard
issueId={id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
scrollContainerRef={scrollContainerRef}
/>
{/* Agent execution history */}
<div className="mt-3">

View File

@@ -162,7 +162,7 @@ function ActorSubContent({
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
!a.archived_at && a.name.toLowerCase().includes(query),
);
const isSelected = (type: "member" | "agent", id: string) =>

View File

@@ -272,6 +272,39 @@ export const en: LandingDict = {
title: "Changelog",
subtitle: "New updates and improvements to Multica.",
entries: [
{
version: "0.1.6",
date: "2026-04-03",
title: "Editor Overhaul & Agent Lifecycle",
changes: [
"Unified Tiptap editor with a single Markdown pipeline for editing and display",
"Reliable Markdown paste, inline code spacing, and link styling",
"Agent archive and restore — soft delete replaces hard delete",
"Archived agents hidden from default agent list",
"Skeleton loading states, error toasts, and confirmation dialogs across the app",
"OpenCode added as a supported agent provider",
"Reply-triggered agent tasks now inherit thread-root @mentions",
"Granular real-time event handling for issues and inbox — no more full refetches",
"Unified image upload flow for paste and button in the editor",
],
},
{
version: "0.1.5",
date: "2026-04-02",
title: "Mentions & Permissions",
changes: [
"@mention issues in comments with server-side auto-expansion",
"@all mention to notify every workspace member",
"Inbox auto-scrolls to the referenced comment from a notification",
"Repositories extracted into a standalone settings tab",
"CLI update support from the web runtime page and direct download for non-Homebrew installs",
"CLI commands for viewing issue execution runs and run messages",
"Agent permission model — owners and admins manage agents, members manage skills on their own agents",
"Per-issue serial execution to prevent concurrent task collisions",
"File upload now supports all file types",
"README redesign with quickstart guide",
],
},
{
version: "0.1.4",
date: "2026-04-01",

View File

@@ -272,6 +272,39 @@ export const zh: LandingDict = {
title: "\u66f4\u65b0\u65e5\u5fd7",
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
entries: [
{
version: "0.1.6",
date: "2026-04-03",
title: "编辑器重构与 Agent 生命周期",
changes: [
"统一 Tiptap 编辑器,编辑和展示共用单一 Markdown 渲染管线",
"Markdown 粘贴、行内代码间距和链接样式修复",
"Agent 支持归档和恢复——软删除替代硬删除",
"默认列表隐藏已归档的 Agent",
"全应用新增骨架屏加载态、错误提示和确认对话框",
"新增 OpenCode 作为支持的 Agent 提供商",
"回复触发的 Agent 任务自动继承主线程 @提及",
"Issue 和收件箱实时事件细粒度处理,不再全量刷新",
"编辑器中统一图片上传流程,支持粘贴和按钮上传",
],
},
{
version: "0.1.5",
date: "2026-04-02",
title: "提及与权限",
changes: [
"评论中支持 @提及 Issue服务端自动展开",
"支持 @all 提及工作区所有成员",
"收件箱通知点击后自动滚动到对应评论",
"仓库管理独立为设置页单独标签页",
"支持从网页端运行时页面更新 CLI非 Homebrew 安装支持直接下载更新",
"新增 CLI 命令查看 Issue 执行记录和运行消息",
"Agent 权限模型优化——所有者和管理员管理 Agent成员可管理自己 Agent 的技能",
"每个 Issue 串行执行,防止并发任务冲突",
"文件上传支持所有文件类型",
"README 重新设计,新增快速入门指南",
],
},
{
version: "0.1.4",
date: "2026-04-01",

View File

@@ -99,7 +99,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(assigneeQuery));
const assigneeLabel =
assigneeType && assigneeId

View File

@@ -233,7 +233,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
clearWorkspace: () => {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
},
}));

View File

@@ -2,9 +2,11 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/cobra"
@@ -24,10 +26,124 @@ var agentListCmd = &cobra.Command{
RunE: runAgentList,
}
var agentGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get agent details",
Args: cobra.ExactArgs(1),
RunE: runAgentGet,
}
var agentCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new agent",
RunE: runAgentCreate,
}
var agentUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentUpdate,
}
var agentArchiveCmd = &cobra.Command{
Use: "archive <id>",
Short: "Archive an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentArchive,
}
var agentRestoreCmd = &cobra.Command{
Use: "restore <id>",
Short: "Restore an archived agent",
Args: cobra.ExactArgs(1),
RunE: runAgentRestore,
}
var agentTasksCmd = &cobra.Command{
Use: "tasks <id>",
Short: "List tasks for an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentTasks,
}
// Agent skills subcommands.
var agentSkillsCmd = &cobra.Command{
Use: "skills",
Short: "Manage agent skill assignments",
}
var agentSkillsListCmd = &cobra.Command{
Use: "list <agent-id>",
Short: "List skills assigned to an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentSkillsList,
}
var agentSkillsSetCmd = &cobra.Command{
Use: "set <agent-id>",
Short: "Set skills for an agent (replaces all current assignments)",
Args: cobra.ExactArgs(1),
RunE: runAgentSkillsSet,
}
func init() {
agentCmd.AddCommand(agentListCmd)
agentCmd.AddCommand(agentGetCmd)
agentCmd.AddCommand(agentCreateCmd)
agentCmd.AddCommand(agentUpdateCmd)
agentCmd.AddCommand(agentArchiveCmd)
agentCmd.AddCommand(agentRestoreCmd)
agentCmd.AddCommand(agentTasksCmd)
agentCmd.AddCommand(agentSkillsCmd)
agentSkillsCmd.AddCommand(agentSkillsListCmd)
agentSkillsCmd.AddCommand(agentSkillsSetCmd)
// agent list
agentListCmd.Flags().String("output", "table", "Output format: table or json")
agentListCmd.Flags().Bool("include-archived", false, "Include archived agents")
// agent get
agentGetCmd.Flags().String("output", "json", "Output format: table or json")
// agent create
agentCreateCmd.Flags().String("name", "", "Agent name (required)")
agentCreateCmd.Flags().String("description", "", "Agent description")
agentCreateCmd.Flags().String("instructions", "", "Agent instructions")
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace")
agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks")
agentCreateCmd.Flags().String("output", "json", "Output format: table or json")
// agent update
agentUpdateCmd.Flags().String("name", "", "New name")
agentUpdateCmd.Flags().String("description", "", "New description")
agentUpdateCmd.Flags().String("instructions", "", "New instructions")
agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID")
agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string")
agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace")
agentUpdateCmd.Flags().String("status", "", "New status")
agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks")
agentUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// agent archive
agentArchiveCmd.Flags().String("output", "json", "Output format: table or json")
// agent restore
agentRestoreCmd.Flags().String("output", "json", "Output format: table or json")
// agent tasks
agentTasksCmd.Flags().String("output", "table", "Output format: table or json")
// agent skills list
agentSkillsListCmd.Flags().String("output", "table", "Output format: table or json")
// agent skills set
agentSkillsSetCmd.Flags().StringSlice("skill-ids", nil, "Skill IDs to assign (comma-separated)")
agentSkillsSetCmd.Flags().String("output", "json", "Output format: table or json")
}
// resolveProfile returns the --profile flag value (empty string means default profile).
@@ -90,6 +206,10 @@ func resolveWorkspaceID(cmd *cobra.Command) string {
return cfg.WorkspaceID
}
// ---------------------------------------------------------------------------
// Agent commands
// ---------------------------------------------------------------------------
func runAgentList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
@@ -100,9 +220,16 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
defer cancel()
var agents []map[string]any
path := "/api/agents"
params := url.Values{}
if client.WorkspaceID != "" {
path += "?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
params.Set("workspace_id", client.WorkspaceID)
}
if v, _ := cmd.Flags().GetBool("include-archived"); v {
params.Set("include_archived", "true")
}
path := "/api/agents"
if len(params) > 0 {
path += "?" + params.Encode()
}
if err := client.GetJSON(ctx, path, &agents); err != nil {
return fmt.Errorf("list agents: %w", err)
@@ -113,20 +240,342 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
return cli.PrintJSON(os.Stdout, agents)
}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME"}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "ARCHIVED"}
rows := make([][]string, 0, len(agents))
for _, a := range agents {
archived := ""
if v := strVal(a, "archived_at"); v != "" {
archived = "yes"
}
rows = append(rows, []string{
strVal(a, "id"),
strVal(a, "name"),
strVal(a, "status"),
strVal(a, "runtime_mode"),
archived,
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var agent map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil {
return fmt.Errorf("get agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, agent)
}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "VISIBILITY", "DESCRIPTION"}
rows := [][]string{{
strVal(agent, "id"),
strVal(agent, "name"),
strVal(agent, "status"),
strVal(agent, "runtime_mode"),
strVal(agent, "visibility"),
strVal(agent, "description"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentCreate(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
return fmt.Errorf("--name is required")
}
runtimeID, _ := cmd.Flags().GetString("runtime-id")
if runtimeID == "" {
return fmt.Errorf("--runtime-id is required")
}
body := map[string]any{
"name": name,
"runtime_id": runtimeID,
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
body["description"] = v
}
if v, _ := cmd.Flags().GetString("instructions"); v != "" {
body["instructions"] = v
}
if cmd.Flags().Changed("runtime-config") {
v, _ := cmd.Flags().GetString("runtime-config")
var rc any
if err := json.Unmarshal([]byte(v), &rc); err != nil {
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
}
body["runtime_config"] = rc
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
body["visibility"] = v
}
if cmd.Flags().Changed("max-concurrent-tasks") {
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
body["max_concurrent_tasks"] = v
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents", body, &result); err != nil {
return fmt.Errorf("create agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent created: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("name") {
v, _ := cmd.Flags().GetString("name")
body["name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("instructions") {
v, _ := cmd.Flags().GetString("instructions")
body["instructions"] = v
}
if cmd.Flags().Changed("runtime-id") {
v, _ := cmd.Flags().GetString("runtime-id")
body["runtime_id"] = v
}
if cmd.Flags().Changed("runtime-config") {
v, _ := cmd.Flags().GetString("runtime-config")
var rc any
if err := json.Unmarshal([]byte(v), &rc); err != nil {
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
}
body["runtime_config"] = rc
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
body["visibility"] = v
}
if cmd.Flags().Changed("status") {
v, _ := cmd.Flags().GetString("status")
body["status"] = v
}
if cmd.Flags().Changed("max-concurrent-tasks") {
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
body["max_concurrent_tasks"] = v
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --visibility, --status, or --max-concurrent-tasks")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, &result); err != nil {
return fmt.Errorf("update agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentArchive(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/archive", nil, &result); err != nil {
return fmt.Errorf("archive agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent archived: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentRestore(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/restore", nil, &result); err != nil {
return fmt.Errorf("restore agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent restored: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentTasks(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var tasks []map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/tasks", &tasks); err != nil {
return fmt.Errorf("list agent tasks: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, tasks)
}
headers := []string{"ID", "ISSUE_ID", "STATUS", "CREATED_AT"}
rows := make([][]string, 0, len(tasks))
for _, t := range tasks {
rows = append(rows, []string{
strVal(t, "id"),
strVal(t, "issue_id"),
strVal(t, "status"),
strVal(t, "created_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
// ---------------------------------------------------------------------------
// Agent skills subcommands
// ---------------------------------------------------------------------------
func runAgentSkillsList(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var skills []map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/skills", &skills); err != nil {
return fmt.Errorf("list agent skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, skills)
}
headers := []string{"ID", "NAME", "DESCRIPTION"}
rows := make([][]string, 0, len(skills))
for _, s := range skills {
rows = append(rows, []string{
strVal(s, "id"),
strVal(s, "name"),
strVal(s, "description"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentSkillsSet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
if !cmd.Flags().Changed("skill-ids") {
return fmt.Errorf("--skill-ids is required (comma-separated skill IDs; use --skill-ids '' to clear all)")
}
skillIDs, _ := cmd.Flags().GetStringSlice("skill-ids")
// Allow passing empty string to clear all skills.
cleanIDs := make([]string, 0, len(skillIDs))
for _, id := range skillIDs {
id = strings.TrimSpace(id)
if id != "" {
cleanIDs = append(cleanIDs, id)
}
}
body := map[string]any{
"skill_ids": cleanIDs,
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result json.RawMessage
if err := client.PutJSON(ctx, "/api/agents/"+args[0]+"/skills", body, &result); err != nil {
return fmt.Errorf("set agent skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
var pretty any
json.Unmarshal(result, &pretty)
return cli.PrintJSON(os.Stdout, pretty)
}
fmt.Printf("Skills updated for agent %s\n", args[0])
return nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func strVal(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {

View File

@@ -162,6 +162,9 @@ func init() {
// issue comment list
issueCommentListCmd.Flags().String("output", "table", "Output format: table or json")
issueCommentListCmd.Flags().Int("limit", 0, "Maximum number of comments to return (0 = all)")
issueCommentListCmd.Flags().Int("offset", 0, "Number of comments to skip")
issueCommentListCmd.Flags().String("since", "", "Only return comments created after this timestamp (RFC3339)")
// issue runs
issueRunsCmd.Flags().String("output", "table", "Output format: table or json")
@@ -536,9 +539,36 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
params := url.Values{}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
params.Set("limit", fmt.Sprintf("%d", v))
}
if v, _ := cmd.Flags().GetInt("offset"); v > 0 {
params.Set("offset", fmt.Sprintf("%d", v))
}
if v, _ := cmd.Flags().GetString("since"); v != "" {
params.Set("since", v)
}
path := "/api/issues/" + args[0] + "/comments"
if len(params) > 0 {
path += "?" + params.Encode()
}
var comments []map[string]any
if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/comments", &comments); err != nil {
return fmt.Errorf("list comments: %w", err)
isPaginated := len(params) > 0
if isPaginated {
headers, getErr := client.GetJSONWithHeaders(ctx, path, &comments)
if getErr != nil {
return fmt.Errorf("list comments: %w", getErr)
}
if total := headers.Get("X-Total-Count"); total != "" {
fmt.Fprintf(os.Stderr, "Showing %d of %s comments.\n", len(comments), total)
}
} else {
if err := client.GetJSON(ctx, path, &comments); err != nil {
return fmt.Errorf("list comments: %w", err)
}
}
output, _ := cmd.Flags().GetString("output")

View File

@@ -0,0 +1,306 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var runtimeCmd = &cobra.Command{
Use: "runtime",
Short: "Manage agent runtimes",
}
var runtimeListCmd = &cobra.Command{
Use: "list",
Short: "List runtimes in the workspace",
RunE: runRuntimeList,
}
var runtimeUsageCmd = &cobra.Command{
Use: "usage <runtime-id>",
Short: "Get token usage for a runtime",
Args: cobra.ExactArgs(1),
RunE: runRuntimeUsage,
}
var runtimeActivityCmd = &cobra.Command{
Use: "activity <runtime-id>",
Short: "Get hourly task activity for a runtime",
Args: cobra.ExactArgs(1),
RunE: runRuntimeActivity,
}
var runtimePingCmd = &cobra.Command{
Use: "ping <runtime-id>",
Short: "Ping a runtime to check connectivity",
Args: cobra.ExactArgs(1),
RunE: runRuntimePing,
}
var runtimeUpdateCmd = &cobra.Command{
Use: "update <runtime-id>",
Short: "Initiate a CLI update on a runtime",
Args: cobra.ExactArgs(1),
RunE: runRuntimeUpdate,
}
func init() {
runtimeCmd.AddCommand(runtimeListCmd)
runtimeCmd.AddCommand(runtimeUsageCmd)
runtimeCmd.AddCommand(runtimeActivityCmd)
runtimeCmd.AddCommand(runtimePingCmd)
runtimeCmd.AddCommand(runtimeUpdateCmd)
// runtime list
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
// runtime usage
runtimeUsageCmd.Flags().String("output", "table", "Output format: table or json")
runtimeUsageCmd.Flags().Int("days", 90, "Number of days of usage data to retrieve (max 365)")
// runtime activity
runtimeActivityCmd.Flags().String("output", "table", "Output format: table or json")
// runtime ping
runtimePingCmd.Flags().String("output", "json", "Output format: table or json")
runtimePingCmd.Flags().Bool("wait", false, "Wait for ping to complete (poll until done)")
// runtime update
runtimeUpdateCmd.Flags().String("target-version", "", "Target version to update to (required)")
runtimeUpdateCmd.Flags().String("output", "json", "Output format: table or json")
runtimeUpdateCmd.Flags().Bool("wait", false, "Wait for update to complete (poll until done)")
}
// ---------------------------------------------------------------------------
// Runtime commands
// ---------------------------------------------------------------------------
func runRuntimeList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var runtimes []map[string]any
if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil {
return fmt.Errorf("list runtimes: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, runtimes)
}
headers := []string{"ID", "NAME", "MODE", "PROVIDER", "STATUS", "LAST_SEEN"}
rows := make([][]string, 0, len(runtimes))
for _, rt := range runtimes {
rows = append(rows, []string{
strVal(rt, "id"),
strVal(rt, "name"),
strVal(rt, "runtime_mode"),
strVal(rt, "provider"),
strVal(rt, "status"),
strVal(rt, "last_seen_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimeUsage(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
days, _ := cmd.Flags().GetInt("days")
if days < 1 || days > 365 {
return fmt.Errorf("--days must be between 1 and 365")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var usage []map[string]any
path := fmt.Sprintf("/api/runtimes/%s/usage?days=%d", args[0], days)
if err := client.GetJSON(ctx, path, &usage); err != nil {
return fmt.Errorf("get runtime usage: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, usage)
}
headers := []string{"DATE", "PROVIDER", "MODEL", "INPUT_TOKENS", "OUTPUT_TOKENS", "CACHE_READ", "CACHE_WRITE"}
rows := make([][]string, 0, len(usage))
for _, u := range usage {
rows = append(rows, []string{
strVal(u, "date"),
strVal(u, "provider"),
strVal(u, "model"),
strVal(u, "input_tokens"),
strVal(u, "output_tokens"),
strVal(u, "cache_read_tokens"),
strVal(u, "cache_write_tokens"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimeActivity(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var activity []map[string]any
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/activity", &activity); err != nil {
return fmt.Errorf("get runtime activity: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, activity)
}
headers := []string{"HOUR", "COUNT"}
rows := make([][]string, 0, len(activity))
for _, a := range activity {
rows = append(rows, []string{
strVal(a, "hour"),
strVal(a, "count"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimePing(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
// Initiate ping.
var ping map[string]any
if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/ping", nil, &ping); err != nil {
return fmt.Errorf("initiate ping: %w", err)
}
wait, _ := cmd.Flags().GetBool("wait")
if !wait {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, ping)
}
fmt.Printf("Ping initiated: %s (status: %s)\n", strVal(ping, "id"), strVal(ping, "status"))
return nil
}
// Poll until completed/failed/timeout.
pingID := strVal(ping, "id")
for {
select {
case <-ctx.Done():
return fmt.Errorf("timed out waiting for ping (last status: %s)", strVal(ping, "status"))
case <-time.After(1 * time.Second):
}
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/ping/"+pingID, &ping); err != nil {
return fmt.Errorf("get ping status: %w", err)
}
status := strVal(ping, "status")
if status == "completed" || status == "failed" || status == "timeout" {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, ping)
}
if status == "completed" {
fmt.Printf("Ping completed in %sms\n", strVal(ping, "duration_ms"))
} else {
fmt.Printf("Ping %s: %s\n", status, strVal(ping, "error"))
}
return nil
}
}
}
func runRuntimeUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
targetVersion, _ := cmd.Flags().GetString("target-version")
if targetVersion == "" {
return fmt.Errorf("--target-version is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second)
defer cancel()
body := map[string]any{
"target_version": targetVersion,
}
var update map[string]any
if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/update", body, &update); err != nil {
return fmt.Errorf("initiate update: %w", err)
}
wait, _ := cmd.Flags().GetBool("wait")
if !wait {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, update)
}
fmt.Printf("Update initiated: %s (status: %s)\n", strVal(update, "id"), strVal(update, "status"))
return nil
}
// Poll until completed/failed/timeout.
updateID := strVal(update, "id")
for {
select {
case <-ctx.Done():
return fmt.Errorf("timed out waiting for update (last status: %s)", strVal(update, "status"))
case <-time.After(2 * time.Second):
}
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/update/"+updateID, &update); err != nil {
return fmt.Errorf("get update status: %w", err)
}
status := strVal(update, "status")
if status == "completed" || status == "failed" || status == "timeout" {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, update)
}
if status == "completed" {
fmt.Printf("Update completed: %s\n", strVal(update, "output"))
} else {
fmt.Printf("Update %s: %s\n", status, strVal(update, "error"))
}
return nil
}
}
}

View File

@@ -0,0 +1,450 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var skillCmd = &cobra.Command{
Use: "skill",
Short: "Manage skills",
}
var skillListCmd = &cobra.Command{
Use: "list",
Short: "List skills in the workspace",
RunE: runSkillList,
}
var skillGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get skill details (includes files)",
Args: cobra.ExactArgs(1),
RunE: runSkillGet,
}
var skillCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new skill",
RunE: runSkillCreate,
}
var skillUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillUpdate,
}
var skillDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillDelete,
}
var skillImportCmd = &cobra.Command{
Use: "import",
Short: "Import a skill from a URL (clawhub.ai or skills.sh)",
RunE: runSkillImport,
}
// Skill file subcommands.
var skillFilesCmd = &cobra.Command{
Use: "files",
Short: "Manage skill files",
}
var skillFilesListCmd = &cobra.Command{
Use: "list <skill-id>",
Short: "List files for a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillFilesList,
}
var skillFilesUpsertCmd = &cobra.Command{
Use: "upsert <skill-id>",
Short: "Create or update a skill file",
Args: cobra.ExactArgs(1),
RunE: runSkillFilesUpsert,
}
var skillFilesDeleteCmd = &cobra.Command{
Use: "delete <skill-id> <file-id>",
Short: "Delete a skill file",
Args: cobra.ExactArgs(2),
RunE: runSkillFilesDelete,
}
func init() {
skillCmd.AddCommand(skillListCmd)
skillCmd.AddCommand(skillGetCmd)
skillCmd.AddCommand(skillCreateCmd)
skillCmd.AddCommand(skillUpdateCmd)
skillCmd.AddCommand(skillDeleteCmd)
skillCmd.AddCommand(skillImportCmd)
skillCmd.AddCommand(skillFilesCmd)
skillFilesCmd.AddCommand(skillFilesListCmd)
skillFilesCmd.AddCommand(skillFilesUpsertCmd)
skillFilesCmd.AddCommand(skillFilesDeleteCmd)
// skill list
skillListCmd.Flags().String("output", "table", "Output format: table or json")
// skill get
skillGetCmd.Flags().String("output", "json", "Output format: table or json")
// skill create
skillCreateCmd.Flags().String("name", "", "Skill name (required)")
skillCreateCmd.Flags().String("description", "", "Skill description")
skillCreateCmd.Flags().String("content", "", "Skill content (SKILL.md body)")
skillCreateCmd.Flags().String("config", "", "Skill config as JSON string")
skillCreateCmd.Flags().String("output", "json", "Output format: table or json")
// skill update
skillUpdateCmd.Flags().String("name", "", "New name")
skillUpdateCmd.Flags().String("description", "", "New description")
skillUpdateCmd.Flags().String("content", "", "New content")
skillUpdateCmd.Flags().String("config", "", "New config as JSON string")
skillUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// skill delete
skillDeleteCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
// skill import
skillImportCmd.Flags().String("url", "", "URL to import from (required)")
skillImportCmd.Flags().String("output", "json", "Output format: table or json")
// skill files list
skillFilesListCmd.Flags().String("output", "table", "Output format: table or json")
// skill files upsert
skillFilesUpsertCmd.Flags().String("path", "", "File path within the skill (required)")
skillFilesUpsertCmd.Flags().String("content", "", "File content (required)")
skillFilesUpsertCmd.Flags().String("output", "json", "Output format: table or json")
}
// ---------------------------------------------------------------------------
// Skill commands
// ---------------------------------------------------------------------------
func runSkillList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var skills []map[string]any
if err := client.GetJSON(ctx, "/api/skills", &skills); err != nil {
return fmt.Errorf("list skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, skills)
}
headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"}
rows := make([][]string, 0, len(skills))
for _, s := range skills {
rows = append(rows, []string{
strVal(s, "id"),
strVal(s, "name"),
strVal(s, "description"),
strVal(s, "created_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runSkillGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var skill map[string]any
if err := client.GetJSON(ctx, "/api/skills/"+args[0], &skill); err != nil {
return fmt.Errorf("get skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, skill)
}
headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"}
rows := [][]string{{
strVal(skill, "id"),
strVal(skill, "name"),
strVal(skill, "description"),
strVal(skill, "created_at"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runSkillCreate(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
return fmt.Errorf("--name is required")
}
body := map[string]any{
"name": name,
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
body["description"] = v
}
if v, _ := cmd.Flags().GetString("content"); v != "" {
body["content"] = v
}
if cmd.Flags().Changed("config") {
v, _ := cmd.Flags().GetString("config")
var config any
if err := json.Unmarshal([]byte(v), &config); err != nil {
return fmt.Errorf("--config must be valid JSON: %w", err)
}
body["config"] = config
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/skills", body, &result); err != nil {
return fmt.Errorf("create skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill created: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runSkillUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("name") {
v, _ := cmd.Flags().GetString("name")
body["name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("content") {
v, _ := cmd.Flags().GetString("content")
body["content"] = v
}
if cmd.Flags().Changed("config") {
v, _ := cmd.Flags().GetString("config")
var config any
if err := json.Unmarshal([]byte(v), &config); err != nil {
return fmt.Errorf("--config must be valid JSON: %w", err)
}
body["config"] = config
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use --name, --description, --content, or --config")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PutJSON(ctx, "/api/skills/"+args[0], body, &result); err != nil {
return fmt.Errorf("update skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runSkillDelete(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
if !yes {
fmt.Printf("Are you sure you want to delete skill %s? This cannot be undone. [y/N] ", args[0])
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Aborted.")
return nil
}
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]); err != nil {
return fmt.Errorf("delete skill: %w", err)
}
fmt.Printf("Skill deleted: %s\n", args[0])
return nil
}
func runSkillImport(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
importURL, _ := cmd.Flags().GetString("url")
if importURL == "" {
return fmt.Errorf("--url is required")
}
body := map[string]any{
"url": importURL,
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
return fmt.Errorf("import skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
// ---------------------------------------------------------------------------
// Skill file subcommands
// ---------------------------------------------------------------------------
func runSkillFilesList(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var files []map[string]any
if err := client.GetJSON(ctx, "/api/skills/"+args[0]+"/files", &files); err != nil {
return fmt.Errorf("list skill files: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, files)
}
headers := []string{"ID", "PATH", "CREATED_AT", "UPDATED_AT"}
rows := make([][]string, 0, len(files))
for _, f := range files {
rows = append(rows, []string{
strVal(f, "id"),
strVal(f, "path"),
strVal(f, "created_at"),
strVal(f, "updated_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runSkillFilesUpsert(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
filePath, _ := cmd.Flags().GetString("path")
if filePath == "" {
return fmt.Errorf("--path is required")
}
content, _ := cmd.Flags().GetString("content")
if content == "" {
return fmt.Errorf("--content is required")
}
body := map[string]any{
"path": filePath,
"content": content,
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PutJSON(ctx, "/api/skills/"+args[0]+"/files", body, &result); err != nil {
return fmt.Errorf("upsert skill file: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill file upserted: %s (%s)\n", strVal(result, "path"), strVal(result, "id"))
return nil
}
func runSkillFilesDelete(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]+"/files/"+args[1]); err != nil {
return fmt.Errorf("delete skill file: %w", err)
}
fmt.Printf("Skill file deleted: %s\n", args[1])
return nil
}

View File

@@ -36,6 +36,8 @@ func init() {
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(skillCmd)
rootCmd.AddCommand(runtimeCmd)
}
func main() {

View File

@@ -402,3 +402,33 @@ func TestCommentTriggerCoalescing(t *testing.T) {
t.Errorf("expected 1 pending task (coalescing), got %d", n)
}
}
// TestCommentTriggerMentionAssigneeDoneIssue verifies that @mentioning the
// assigned agent on a done issue still triggers execution. Previously the
// assignee was unconditionally skipped in the mention path (assuming
// on_comment handled it), but on_comment is suppressed for terminal statuses.
func TestCommentTriggerMentionAssigneeDoneIssue(t *testing.T) {
agentID := getAgentID(t)
// Create an issue assigned to the agent, then mark it done.
issueID := createIssueAssignedToAgent(t, "Mention-assignee-done test", agentID)
clearTasks(t, issueID) // clear any tasks from assignment
resp := authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
"status": "done",
})
resp.Body.Close()
t.Cleanup(func() {
clearTasks(t, issueID)
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
resp.Body.Close()
})
// @mention the assigned agent on the done issue — should trigger.
content := fmt.Sprintf("[@Agent](mention://agent/%s) reopen this please", agentID)
postComment(t, issueID, content, nil)
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task after @mention of assignee on done issue, got %d", n)
}
}

View File

@@ -77,6 +77,34 @@ func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
return json.NewDecoder(resp.Body).Decode(out)
}
// GetJSONWithHeaders performs a GET request, decodes the JSON response, and
// returns the response headers. Useful when callers need header values like
// X-Total-Count for pagination.
func (c *APIClient) GetJSONWithHeaders(ctx context.Context, path string, out any) (http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return nil, err
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return resp.Header, err
}
}
return resp.Header, nil
}
// DeleteJSON performs a DELETE request.
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)

View File

@@ -47,7 +47,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("### Read\n")
b.WriteString("- `multica issue get <id> --output json` — Get full issue details (title, description, status, priority, assignee)\n")
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X] --output json` — List issues in workspace\n")
b.WriteString("- `multica issue comment list <issue-id> --output json` — List all comments on an issue (includes id, parent_id for threading)\n")
b.WriteString("- `multica issue comment list <issue-id> [--limit N] [--offset N] [--since <RFC3339>] --output json` — List comments on an issue (supports pagination; includes id, parent_id for threading)\n")
b.WriteString("- `multica workspace get --output json` — Get workspace details and context\n")
b.WriteString("- `multica agent list --output json` — List agents in workspace\n")
b.WriteString("- `multica issue runs <issue-id> --output json` — List all execution runs for an issue (status, timestamps, errors)\n")
@@ -83,6 +83,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("**This task was triggered by a comment.** Your primary job is to respond.\n\n")
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand the issue context\n", ctx.IssueID)
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation\n", ctx.IssueID)
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked\n", ctx.TriggerCommentID)
fmt.Fprintf(&b, "4. Reply: `multica issue comment add %s --parent %s --content \"...\"`\n", ctx.IssueID, ctx.TriggerCommentID)
b.WriteString("5. If the comment requests code changes or further work, do the work first, then reply with your results\n")

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -58,10 +60,81 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
return
}
comments, err := h.Queries.ListComments(r.Context(), db.ListCommentsParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
// Parse optional pagination query params.
q := r.URL.Query()
var limit, offset int32
var hasPagination bool
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "invalid limit parameter")
return
}
limit = int32(n)
hasPagination = true
}
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
writeError(w, http.StatusBadRequest, "invalid offset parameter")
return
}
offset = int32(n)
hasPagination = true
}
var sinceTime pgtype.Timestamptz
if v := q.Get("since"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid since parameter; expected RFC3339 format")
return
}
sinceTime = pgtype.Timestamptz{Time: t, Valid: true}
}
var comments []db.Comment
var err error
switch {
case sinceTime.Valid && hasPagination:
if limit == 0 {
limit = 50
}
comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: sinceTime,
Limit: limit,
Offset: offset,
})
case sinceTime.Valid:
// Apply a server-side cap to prevent unbounded result sets when
// --since is used without --limit.
comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: sinceTime,
Limit: 500,
Offset: 0,
})
hasPagination = true
case hasPagination:
if limit == 0 {
limit = 50
}
comments, err = h.Queries.ListCommentsPaginated(r.Context(), db.ListCommentsPaginatedParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Limit: limit,
Offset: offset,
})
default:
comments, err = h.Queries.ListComments(r.Context(), db.ListCommentsParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
@@ -80,6 +153,17 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
}
// Include total count in response header when paginating.
if hasPagination {
total, countErr := h.Queries.CountComments(r.Context(), db.CountCommentsParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
if countErr == nil {
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
}
}
writeJSON(w, http.StatusOK, resp)
}
@@ -202,8 +286,16 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// is announcing to everyone, not specifically requesting work from the agent.
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
mentions := util.ParseMentions(content)
// Filter out issue mentions — they are cross-references, not @people.
filtered := mentions[:0]
for _, m := range mentions {
if m.Type != "issue" {
filtered = append(filtered, m)
}
}
mentions = filtered
if len(mentions) == 0 {
return false // No mentions — normal on_comment behavior
return false // No mentions (or only issue refs) — normal on_comment behavior
}
// @all is a broadcast to all members — suppress agent trigger.
if util.HasMentionAll(mentions) {
@@ -297,9 +389,14 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
}
agentUUID := parseUUID(m.ID)
// Prevent duplicate: skip if this agent is the issue's assignee
// (already handled by the on_comment trigger above).
if issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID {
// (already handled by the on_comment trigger above) — but only
// when the issue is in a non-terminal status where on_comment
// will actually fire. For done/cancelled issues on_comment is
// suppressed, so an explicit @mention must still go through.
isAssignee := issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID
isTerminal := issue.Status == "done" || issue.Status == "cancelled"
if isAssignee && !isTerminal {
continue
}
// Load the agent to check visibility, archive status, and trigger config.

View File

@@ -143,10 +143,21 @@ func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
return
}
// Archive all sibling inbox items for the same issue (issue-level archive)
if item.IssueID.Valid {
h.Queries.ArchiveInboxByIssue(r.Context(), db.ArchiveInboxByIssueParams{
WorkspaceID: item.WorkspaceID,
RecipientType: item.RecipientType,
RecipientID: item.RecipientID,
IssueID: item.IssueID,
})
}
userID := requestUserID(r)
workspaceID := uuidToString(item.WorkspaceID)
h.publish(protocol.EventInboxArchived, workspaceID, "member", userID, map[string]any{
"item_id": uuidToString(item.ID),
"issue_id": uuidToPtr(item.IssueID),
"recipient_id": uuidToString(item.RecipientID),
})

View File

@@ -86,6 +86,16 @@ func TestCommentMentionsOthersButNotAssignee(t *testing.T) {
content: fmt.Sprintf("[@All](mention://all/all) [@Agent](mention://agent/%s) fyi", agentAssigneeID),
want: true,
},
{
name: "issue mention only → allow trigger (cross-reference, not @person)",
content: "[PAN-1](mention://issue/44c266e7-f6dd-4be3-9140-5ac40233f79c) is related",
want: false,
},
{
name: "issue mention + other agent → suppress (agent mention matters)",
content: fmt.Sprintf("[PAN-1](mention://issue/44c266e7-f6dd-4be3-9140-5ac40233f79c) cc [@Other](mention://agent/%s)", otherAgentID),
want: true,
},
}
for _, tt := range tests {

View File

@@ -11,6 +11,23 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countComments = `-- name: CountComments :one
SELECT count(*) FROM comment
WHERE issue_id = $1 AND workspace_id = $2
`
type CountCommentsParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) CountComments(ctx context.Context, arg CountCommentsParams) (int64, error) {
row := q.db.QueryRow(ctx, countComments, arg.IssueID, arg.WorkspaceID)
var count int64
err := row.Scan(&count)
return count, err
}
const createComment = `-- name: CreateComment :one
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
@@ -155,6 +172,151 @@ func (q *Queries) ListComments(ctx context.Context, arg ListCommentsParams) ([]C
return items, nil
}
const listCommentsPaginated = `-- name: ListCommentsPaginated :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC
LIMIT $3 OFFSET $4
`
type ListCommentsPaginatedParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListCommentsPaginated(ctx context.Context, arg ListCommentsPaginatedParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsPaginated,
arg.IssueID,
arg.WorkspaceID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsSince = `-- name: ListCommentsSince :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC
`
type ListCommentsSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) ListCommentsSince(ctx context.Context, arg ListCommentsSinceParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsSince, arg.IssueID, arg.WorkspaceID, arg.CreatedAt)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsSincePaginated = `-- name: ListCommentsSincePaginated :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC
LIMIT $4 OFFSET $5
`
type ListCommentsSincePaginatedParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListCommentsSincePaginated(ctx context.Context, arg ListCommentsSincePaginatedParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsSincePaginated,
arg.IssueID,
arg.WorkspaceID,
arg.CreatedAt,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateComment = `-- name: UpdateComment :one
UPDATE comment SET
content = $2,

View File

@@ -66,6 +66,31 @@ func (q *Queries) ArchiveCompletedInbox(ctx context.Context, arg ArchiveComplete
return result.RowsAffected(), nil
}
const archiveInboxByIssue = `-- name: ArchiveInboxByIssue :execrows
UPDATE inbox_item SET archived = true
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND issue_id = $4 AND archived = false
`
type ArchiveInboxByIssueParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID pgtype.UUID `json:"recipient_id"`
IssueID pgtype.UUID `json:"issue_id"`
}
func (q *Queries) ArchiveInboxByIssue(ctx context.Context, arg ArchiveInboxByIssueParams) (int64, error) {
result, err := q.db.Exec(ctx, archiveInboxByIssue,
arg.WorkspaceID,
arg.RecipientType,
arg.RecipientID,
arg.IssueID,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const archiveInboxItem = `-- name: ArchiveInboxItem :one
UPDATE inbox_item SET archived = true
WHERE id = $1

View File

@@ -3,6 +3,27 @@ SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC;
-- name: ListCommentsPaginated :many
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC
LIMIT $3 OFFSET $4;
-- name: ListCommentsSince :many
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC;
-- name: ListCommentsSincePaginated :many
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC
LIMIT $4 OFFSET $5;
-- name: CountComments :one
SELECT count(*) FROM comment
WHERE issue_id = $1 AND workspace_id = $2;
-- name: GetComment :one
SELECT * FROM comment
WHERE id = $1;

View File

@@ -32,6 +32,10 @@ UPDATE inbox_item SET archived = true
WHERE id = $1
RETURNING *;
-- name: ArchiveInboxByIssue :execrows
UPDATE inbox_item SET archived = true
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND issue_id = $4 AND archived = false;
-- name: CountUnreadInbox :one
SELECT count(*) FROM inbox_item
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false;