mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 17:47:43 +02:00
Compare commits
55 Commits
v0.1.14
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a515fc0be | ||
|
|
451715f5a1 | ||
|
|
fdf594155c | ||
|
|
c39470a53f | ||
|
|
e5dfb34a2a | ||
|
|
58549975e0 | ||
|
|
0bbc6bc1c5 | ||
|
|
beeb8bc107 | ||
|
|
5548d60dbb | ||
|
|
9fb25f4543 | ||
|
|
33140d4c5a | ||
|
|
9b8cc0870b | ||
|
|
ce40b66c60 | ||
|
|
56b49cb2a6 | ||
|
|
4353340ea6 | ||
|
|
91cbf32fd1 | ||
|
|
10b482fac2 | ||
|
|
0024208354 | ||
|
|
32a3a3543d | ||
|
|
e314badf18 | ||
|
|
fc6405e4be | ||
|
|
7b610a4013 | ||
|
|
978e81a268 | ||
|
|
c9c8230271 | ||
|
|
b84543e634 | ||
|
|
6c651f4be5 | ||
|
|
b5924ffa99 | ||
|
|
30640436c4 | ||
|
|
eb355dbc9c | ||
|
|
f34ed091e7 | ||
|
|
d9a6b8c8ed | ||
|
|
27e58d91af | ||
|
|
6799458807 | ||
|
|
8eb1caa72b | ||
|
|
35b379d688 | ||
|
|
392a8d7c8c | ||
|
|
fd744c331e | ||
|
|
a98f165458 | ||
|
|
097630c733 | ||
|
|
c3cca50f27 | ||
|
|
36ba23b3cd | ||
|
|
5df444ba00 | ||
|
|
e6a1ff4354 | ||
|
|
7cc4e63e0e | ||
|
|
36db325d50 | ||
|
|
d751373368 | ||
|
|
09764c5f51 | ||
|
|
565afed447 | ||
|
|
222f60d2dd | ||
|
|
e8c2a8eff9 | ||
|
|
7f0cb106bd | ||
|
|
c7fda85a3e | ||
|
|
b8b4731602 | ||
|
|
fe975fb2bb | ||
|
|
fc0ef0fcd8 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.26.1"
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Build
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ apps/web/test-results/
|
||||
|
||||
# local settings
|
||||
.claude/
|
||||
.tool-versions
|
||||
|
||||
# feature tracking
|
||||
_features/
|
||||
|
||||
@@ -149,7 +149,7 @@ make db-down # Stop shared PostgreSQL
|
||||
|
||||
### CI Requirements
|
||||
|
||||
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||
|
||||
### Worktree Support
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import {
|
||||
Bot,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Lock,
|
||||
Settings,
|
||||
Camera,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -70,6 +71,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
@@ -328,6 +330,7 @@ function AgentListItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -336,11 +339,11 @@ function AgentListItem({
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className="rounded-lg" />
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className={`rounded-lg ${isArchived ? "opacity-50 grayscale" : ""}`} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{agent.name}</span>
|
||||
<span className={`truncate text-sm font-medium ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</span>
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
@@ -348,8 +351,14 @@ function AgentListItem({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
{isArchived ? (
|
||||
<span className="text-xs text-muted-foreground">Archived</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -380,6 +389,8 @@ function InstructionsTab({
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(value);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -446,6 +457,8 @@ function SkillsTab({
|
||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
await refreshAgents();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add skill");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setShowPicker(false);
|
||||
@@ -458,6 +471,8 @@ function SkillsTab({
|
||||
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
await refreshAgents();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -701,6 +716,8 @@ function ToolsTab({
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(tools);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -845,6 +862,8 @@ function TriggersTab({
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(triggers);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1050,8 +1069,17 @@ function TasksTab({ agent }: { agent: Agent }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
Loading tasks...
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border px-4 py-3">
|
||||
<Skeleton className="h-4 w-4 rounded shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1346,30 +1374,50 @@ function AgentDetail({
|
||||
agent,
|
||||
runtimes,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onRestore,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimes: RuntimeDevice[];
|
||||
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onArchive: (id: string) => Promise<void>;
|
||||
onRestore: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const runtimeDevice = getRuntimeDevice(agent, runtimes);
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Archive Banner */}
|
||||
{isArchived && (
|
||||
<div className="flex items-center gap-2 bg-muted/50 px-4 py-2 text-xs text-muted-foreground border-b">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">This agent is archived. It cannot be assigned or mentioned.</span>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={() => onRestore(agent.id)}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className="rounded-md" />
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className={`rounded-md ${isArchived ? "opacity-50" : ""}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold truncate">{agent.name}</h2>
|
||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
<h2 className={`text-sm font-semibold truncate ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</h2>
|
||||
{isArchived ? (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
) : (
|
||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3" />
|
||||
@@ -1380,24 +1428,26 @@ function AgentDetail({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
{!isArchived && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -1451,33 +1501,33 @@ function AgentDetail({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{confirmDelete && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
||||
{/* Archive Confirmation */}
|
||||
{confirmArchive && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmArchive(false); }}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">Delete agent?</DialogTitle>
|
||||
<DialogTitle className="text-sm font-semibold">Archive agent?</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
This will permanently delete "{agent.name}" and all its configuration.
|
||||
"{agent.name}" will be archived. It won't be assignable or mentionable, but all history is preserved. You can restore it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
<Button variant="ghost" onClick={() => setConfirmArchive(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(agent.id);
|
||||
setConfirmArchive(false);
|
||||
onArchive(agent.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
Archive
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1497,6 +1547,7 @@ export default function AgentsPage() {
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const runtimes = useRuntimeStore((s) => s.runtimes);
|
||||
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
||||
@@ -1508,12 +1559,19 @@ export default function AgentsPage() {
|
||||
if (workspace) fetchRuntimes();
|
||||
}, [workspace, fetchRuntimes]);
|
||||
|
||||
// Select first agent on initial load
|
||||
const filteredAgents = useMemo(
|
||||
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
|
||||
[agents, showArchived],
|
||||
);
|
||||
|
||||
const archivedCount = useMemo(() => agents.filter((a) => !!a.archived_at).length, [agents]);
|
||||
|
||||
// Select first agent on initial load or when filter changes
|
||||
useEffect(() => {
|
||||
if (agents.length > 0 && !selectedId) {
|
||||
setSelectedId(agents[0]!.id);
|
||||
if (filteredAgents.length > 0 && !filteredAgents.some((a) => a.id === selectedId)) {
|
||||
setSelectedId(filteredAgents[0]!.id);
|
||||
}
|
||||
}, [agents, selectedId]);
|
||||
}, [filteredAgents, selectedId]);
|
||||
|
||||
const handleCreate = async (data: CreateAgentRequest) => {
|
||||
const agent = await api.createAgent(data);
|
||||
@@ -1522,25 +1580,74 @@ export default function AgentsPage() {
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
await refreshAgents();
|
||||
try {
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
await refreshAgents();
|
||||
toast.success("Agent updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api.deleteAgent(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = agents.filter((a) => a.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.archiveAgent(id);
|
||||
await refreshAgents();
|
||||
toast.success("Agent archived");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await api.restoreAgent(id);
|
||||
await refreshAgents();
|
||||
toast.success("Agent restored");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||
}
|
||||
await refreshAgents();
|
||||
};
|
||||
|
||||
const selected = agents.find((a) => a.id === selectedId) ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* List skeleton */}
|
||||
<div className="w-72 border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Detail skeleton */}
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<Skeleton className="h-8 w-3/4 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1557,30 +1664,46 @@ export default function AgentsPage() {
|
||||
<div className="overflow-y-auto h-full border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<h1 className="text-sm font-semibold">Agents</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{archivedCount > 0 && (
|
||||
<Button
|
||||
variant={showArchived ? "secondary" : "ghost"}
|
||||
size="icon-xs"
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
title={showArchived ? "Show active agents" : "Show archived agents"}
|
||||
>
|
||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{agents.length === 0 ? (
|
||||
{filteredAgents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Bot className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No agents yet</p>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Agent
|
||||
</Button>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{showArchived ? "No archived agents" : archivedCount > 0 ? "No active agents" : "No agents yet"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{agents.map((agent) => (
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
@@ -1603,7 +1726,8 @@ export default function AgentsPage() {
|
||||
agent={selected}
|
||||
runtimes={runtimes}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
@@ -219,11 +220,20 @@ function InboxListItem({
|
||||
|
||||
export default function InboxPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const selectedKey = searchParams.get("issue") ?? "";
|
||||
const setSelectedKey = (key: string) => {
|
||||
const urlIssue = searchParams.get("issue") ?? "";
|
||||
|
||||
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
|
||||
|
||||
// Sync from URL when searchParams change (e.g. Next.js navigation)
|
||||
useEffect(() => {
|
||||
setSelectedKeyState(urlIssue);
|
||||
}, [urlIssue]);
|
||||
|
||||
const setSelectedKey = useCallback((key: string) => {
|
||||
setSelectedKeyState(key);
|
||||
const url = key ? `/inbox?issue=${key}` : "/inbox";
|
||||
window.history.replaceState(null, "", url);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const items = useInboxStore((s) => s.dedupedItems());
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
|
||||
@@ -104,9 +104,9 @@ vi.mock("@/components/ui/calendar", () => ({
|
||||
Calendar: () => null,
|
||||
}));
|
||||
|
||||
// Mock RichTextEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/components/common/rich-text-editor", () => ({
|
||||
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
// Mock ContentEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/features/editor", () => ({
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -132,6 +132,27 @@ vi.mock("@/components/common/rich-text-editor", () => ({
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => valueRef.current,
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={() => onBlur?.(valueRef.current)}
|
||||
placeholder={placeholder}
|
||||
data-testid="title-editor"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Markdown renderer
|
||||
|
||||
@@ -167,7 +167,7 @@ vi.mock("@/features/issues/config", () => ({
|
||||
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
|
||||
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
|
||||
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
|
||||
done: { label: "Done", iconColor: "text-done", hoverBg: "hover:bg-done/10" },
|
||||
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
|
||||
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
},
|
||||
|
||||
@@ -22,6 +22,17 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
@@ -33,13 +44,17 @@ export function TokensTab() {
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
|
||||
const [tokensLoading, setTokensLoading] = useState(true);
|
||||
|
||||
const loadTokens = useCallback(async () => {
|
||||
try {
|
||||
const list = await api.listPersonalAccessTokens();
|
||||
setTokens(list);
|
||||
} catch {
|
||||
// ignore — tokens section simply stays empty
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to load tokens");
|
||||
} finally {
|
||||
setTokensLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -117,7 +132,21 @@ export function TokensTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{tokens.length > 0 && (
|
||||
{tokensLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : tokens.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{tokens.map((t) => (
|
||||
<Card key={t.id}>
|
||||
@@ -135,7 +164,7 @@ export function TokensTab() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleRevokeToken(t.id)}
|
||||
onClick={() => setRevokeConfirmId(t.id)}
|
||||
disabled={tokenRevoking === t.id}
|
||||
aria-label={`Revoke ${t.name}`}
|
||||
>
|
||||
@@ -152,6 +181,29 @@ export function TokensTab() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AlertDialog open={!!revokeConfirmId} onOpenChange={(v) => { if (!v) setRevokeConfirmId(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke token</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This token will be permanently revoked and can no longer be used. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (revokeConfirmId) await handleRevokeToken(revokeConfirmId);
|
||||
setRevokeConfirmId(null);
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-info: var(--info);
|
||||
--color-done: var(--done);
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-foreground: var(--brand-foreground);
|
||||
--color-priority: var(--priority);
|
||||
@@ -95,6 +96,7 @@
|
||||
--success: oklch(0.55 0.16 145);
|
||||
--warning: oklch(0.75 0.16 85);
|
||||
--info: oklch(0.55 0.18 250);
|
||||
--done: oklch(0.55 0.18 300);
|
||||
--priority: oklch(0.65 0.18 50);
|
||||
--scrollbar-thumb: oklch(0 0 0 / 10%);
|
||||
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
|
||||
@@ -139,6 +141,7 @@
|
||||
--success: oklch(0.65 0.15 145);
|
||||
--warning: oklch(0.70 0.16 85);
|
||||
--info: oklch(0.65 0.18 250);
|
||||
--done: oklch(0.65 0.18 300);
|
||||
--priority: oklch(0.70 0.18 50);
|
||||
--scrollbar-thumb: oklch(1 0 0 / 8%);
|
||||
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);
|
||||
|
||||
@@ -3,33 +3,28 @@
|
||||
import { useRef } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
interface FileUploadButtonProps {
|
||||
onUpload: (file: File) => Promise<UploadResult | null>;
|
||||
onInsert?: (result: UploadResult, isImage: boolean) => void;
|
||||
/** Called with the selected File — caller handles upload. */
|
||||
onSelect: (file: File) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
|
||||
function FileUploadButton({
|
||||
onUpload,
|
||||
onInsert,
|
||||
onSelect,
|
||||
disabled,
|
||||
className,
|
||||
size = "default",
|
||||
}: FileUploadButtonProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
const result = await onUpload(file);
|
||||
if (result && onInsert) {
|
||||
onInsert(result, file.type.startsWith("image/"));
|
||||
}
|
||||
onSelect(file);
|
||||
};
|
||||
|
||||
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
/* Rich text editor: ProseMirror styles using shadcn design tokens */
|
||||
|
||||
.rich-text-editor.ProseMirror {
|
||||
color: var(--foreground);
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
.rich-text-editor.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.rich-text-editor .is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.rich-text-editor h1 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rich-text-editor h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rich-text-editor h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
.rich-text-editor p {
|
||||
margin-top: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
/* First child should not have top margin */
|
||||
.rich-text-editor > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Last child should not have bottom margin */
|
||||
.rich-text-editor > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.rich-text-editor ul {
|
||||
list-style-type: disc;
|
||||
padding-inline-start: 1.25rem;
|
||||
margin: 0.375rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor ol {
|
||||
list-style-type: decimal;
|
||||
padding-inline-start: 1.25rem;
|
||||
margin: 0.375rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor li {
|
||||
margin: 0.125rem 0;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.rich-text-editor li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.rich-text-editor code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8em;
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: calc(var(--radius) * 0.6);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.rich-text-editor pre {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rich-text-editor pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Syntax highlighting — lowlight (hljs) */
|
||||
.rich-text-editor .hljs-keyword,
|
||||
.rich-text-editor .hljs-selector-tag,
|
||||
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
|
||||
|
||||
.rich-text-editor .hljs-string,
|
||||
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
|
||||
|
||||
.rich-text-editor .hljs-comment,
|
||||
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
|
||||
|
||||
.rich-text-editor .hljs-number,
|
||||
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
|
||||
|
||||
.rich-text-editor .hljs-title,
|
||||
.rich-text-editor .hljs-section,
|
||||
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
|
||||
|
||||
.rich-text-editor .hljs-attr,
|
||||
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
|
||||
|
||||
.rich-text-editor .hljs-variable,
|
||||
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
|
||||
|
||||
.rich-text-editor .hljs-type,
|
||||
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
|
||||
|
||||
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
|
||||
|
||||
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .rich-text-editor .hljs-keyword,
|
||||
.dark .rich-text-editor .hljs-selector-tag,
|
||||
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
|
||||
|
||||
.dark .rich-text-editor .hljs-string,
|
||||
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
|
||||
|
||||
.dark .rich-text-editor .hljs-number,
|
||||
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
|
||||
|
||||
.dark .rich-text-editor .hljs-title,
|
||||
.dark .rich-text-editor .hljs-section,
|
||||
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
|
||||
|
||||
.dark .rich-text-editor .hljs-attr,
|
||||
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
|
||||
|
||||
.dark .rich-text-editor .hljs-variable,
|
||||
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
|
||||
|
||||
.dark .rich-text-editor .hljs-type,
|
||||
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
|
||||
|
||||
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
|
||||
|
||||
/* Blockquotes */
|
||||
.rich-text-editor blockquote {
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
.rich-text-editor hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.rich-text-editor a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rich-text-editor a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Mentions */
|
||||
.rich-text-editor .mention {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
/* Strong / emphasis */
|
||||
.rich-text-editor strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-text-editor em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-editor s {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Uploading image placeholder (blob: URLs = in-flight uploads) */
|
||||
.rich-text-editor img[src^="blob:"] {
|
||||
opacity: 0.5;
|
||||
border-radius: var(--radius);
|
||||
animation: rte-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rte-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import { Extension, mergeAttributes } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Slice } from "@tiptap/pm/model";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
import "./rich-text-editor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RichTextEditorProps {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
className?: string;
|
||||
debounceMs?: number;
|
||||
onSubmit?: () => void;
|
||||
onBlur?: () => void;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
}
|
||||
|
||||
interface RichTextEditorRef {
|
||||
getMarkdown: () => string;
|
||||
clearContent: () => void;
|
||||
focus: () => void;
|
||||
insertFile: (filename: string, url: string, isImage: boolean) => void;
|
||||
}
|
||||
|
||||
const LinkExtension = Link.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
HTMLAttributes: {
|
||||
class: "text-primary hover:underline cursor-pointer",
|
||||
},
|
||||
});
|
||||
|
||||
const MentionExtension = Mention.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
suggestion: createMentionSuggestion(),
|
||||
}).extend({
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const type = node.attrs.type ?? "member";
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
{ "data-type": "mention" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-mention-type": node.attrs.type ?? "member",
|
||||
"data-mention-id": node.attrs.id,
|
||||
},
|
||||
),
|
||||
`${prefix}${node.attrs.label ?? node.attrs.id}`,
|
||||
];
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
type: {
|
||||
default: "member",
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-mention-type") ?? "member",
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
|
||||
// and [Label](mention://issue/id) (issue mentions have no @ prefix)
|
||||
markdownTokenizer: {
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = src.match(
|
||||
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parseMarkdown: (token: any, helpers: any) => {
|
||||
return helpers.createNode("mention", token.attributes);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
renderMarkdown: (node: any) => {
|
||||
const { id, label, type = "member" } = node.attrs || {};
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit shortcut extension (Mod+Enter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createSubmitExtension(onSubmit: () => void) {
|
||||
return Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": () => {
|
||||
onSubmit();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown paste extension — parse pasted markdown text as rich text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMarkdownPasteExtension() {
|
||||
return Extension.create({
|
||||
name: "markdownPaste",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownPaste"),
|
||||
props: {
|
||||
clipboardTextParser(text, _context, plainText) {
|
||||
if (!plainText && editor.markdown) {
|
||||
const json = editor.markdown.parse(text);
|
||||
const node = editor.schema.nodeFromJSON(json);
|
||||
return Slice.maxOpen(node.content);
|
||||
}
|
||||
// Plain text fallback
|
||||
const p = editor.schema.nodes.paragraph!;
|
||||
const doc = editor.schema.nodes.doc!;
|
||||
const paragraph = p.create(null, text ? editor.schema.text(text) : undefined);
|
||||
return new Slice(doc.create(null, paragraph).content, 0, 0);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File upload extension (paste + drop) with blob URL instant preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function removeImageBySrc(editor: ReturnType<typeof useEditor>, src: string) {
|
||||
if (!editor) return;
|
||||
const { tr } = editor.state;
|
||||
let deleted = false;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (deleted) return false;
|
||||
if (node.type.name === "image" && node.attrs.src === src) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
deleted = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (deleted) editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
function createFileUploadExtension(
|
||||
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "fileUpload",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
const handleFiles = async (files: FileList, pos?: number) => {
|
||||
const handler = onUploadFileRef.current;
|
||||
if (!handler) return false;
|
||||
|
||||
let handled = false;
|
||||
for (const file of Array.from(files)) {
|
||||
handled = true;
|
||||
const isImage = file.type.startsWith("image/");
|
||||
|
||||
if (isImage) {
|
||||
// Instant preview via blob URL, then replace with real URL after upload
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
if (pos !== undefined) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(pos, {
|
||||
type: "image",
|
||||
attrs: { src: blobUrl, alt: file.name },
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: blobUrl, alt: file.name })
|
||||
.run();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (result) {
|
||||
const { tr } = editor.state;
|
||||
editor.state.doc.descendants((node, nodePos) => {
|
||||
if (
|
||||
node.type.name === "image" &&
|
||||
node.attrs.src === blobUrl
|
||||
) {
|
||||
tr.setNodeMarkup(nodePos, undefined, {
|
||||
...node.attrs,
|
||||
src: result.link,
|
||||
alt: result.filename,
|
||||
});
|
||||
}
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
} else {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
}
|
||||
} catch {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
} else {
|
||||
// Non-image: upload first, then insert link
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (!result) continue;
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
if (pos !== undefined) {
|
||||
editor.chain().focus().insertContentAt(pos, linkText).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(linkText).run();
|
||||
}
|
||||
} catch {
|
||||
// Upload errors handled by the hook/caller via toast
|
||||
}
|
||||
}
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("fileUpload"),
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const files = event.clipboardData?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
handleDrop(_view, event) {
|
||||
const files = (event as DragEvent).dataTransfer?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
||||
function RichTextEditor(
|
||||
{
|
||||
defaultValue = "",
|
||||
onUpdate,
|
||||
placeholder: placeholderText = "",
|
||||
editable = true,
|
||||
className,
|
||||
debounceMs = 300,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onUploadFile,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
const onBlurRef = useRef(onBlur);
|
||||
const onUploadFileRef = useRef(onUploadFile);
|
||||
|
||||
// Helper to get markdown from @tiptap/markdown extension.
|
||||
// Post-processes mention shortcodes [@ id="..." label="..."] → markdown
|
||||
// links, using the Tiptap JSON doc for type info, in case the
|
||||
// renderMarkdown override doesn't take effect.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getEditorMarkdown = (ed: any): string => {
|
||||
const md: string = ed?.getMarkdown?.() ?? "";
|
||||
if (!md || !md.includes("[@ ")) return md;
|
||||
|
||||
// Build type map from editor JSON (which always has the type attr)
|
||||
const json = ed?.getJSON?.();
|
||||
const typeMap = new Map<string, string>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function walk(node: any) {
|
||||
if (node?.type === "mention" && node.attrs?.id) {
|
||||
typeMap.set(node.attrs.id, node.attrs.type || "member");
|
||||
}
|
||||
if (node?.content) node.content.forEach(walk);
|
||||
}
|
||||
if (json) walk(json);
|
||||
|
||||
return md.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match: string, attrString: string) => {
|
||||
const attrs: Record<string, string> = {};
|
||||
const re = /(\w+)="([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrString)) !== null) {
|
||||
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
|
||||
}
|
||||
const { id, label } = attrs;
|
||||
if (!id || !label) return match;
|
||||
const type = typeMap.get(id) || "member";
|
||||
const display = type === "issue" ? label : `@${label}`;
|
||||
return `[${display}](mention://${type}/${id})`;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Keep refs in sync without recreating editor
|
||||
onUpdateRef.current = onUpdate;
|
||||
onSubmitRef.current = onSubmit;
|
||||
onBlurRef.current = onBlur;
|
||||
onUploadFileRef.current = onUploadFile;
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable,
|
||||
content: defaultValue || "",
|
||||
contentType: defaultValue ? "markdown" : undefined,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
link: false,
|
||||
codeBlock: false,
|
||||
}),
|
||||
CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockView);
|
||||
},
|
||||
}).configure({ lowlight }),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholderText,
|
||||
}),
|
||||
LinkExtension,
|
||||
Typography,
|
||||
MentionExtension,
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
|
||||
}),
|
||||
Markdown,
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(() => onSubmitRef.current?.()),
|
||||
createFileUploadExtension(onUploadFileRef),
|
||||
],
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
if (!onUpdateRef.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdateRef.current?.(ed.getMarkdown());
|
||||
}, debounceMs);
|
||||
},
|
||||
onBlur: () => {
|
||||
onBlurRef.current?.();
|
||||
},
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
click(_view, event) {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
const link = (event.target as HTMLElement).closest("a");
|
||||
const href = link?.getAttribute("href");
|
||||
if (href && !href.startsWith("mention://")) {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
class: cn("rich-text-editor text-sm outline-none", className),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => editor?.getMarkdown() ?? "",
|
||||
clearContent: () => {
|
||||
editor?.commands.clearContent();
|
||||
},
|
||||
focus: () => {
|
||||
editor?.commands.focus();
|
||||
},
|
||||
insertFile: (filename: string, url: string, isImage: boolean) => {
|
||||
if (!editor) return;
|
||||
if (isImage) {
|
||||
editor.chain().focus().setImage({ src: url, alt: filename }).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
},
|
||||
);
|
||||
|
||||
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };
|
||||
@@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
import { preprocessLinks } from './linkify'
|
||||
import { preprocessMentionShortcodes } from './mentions'
|
||||
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
|
||||
|
||||
/**
|
||||
@@ -53,27 +54,6 @@ function urlTransform(url: string): string {
|
||||
return defaultUrlTransform(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown
|
||||
* link format [@LABEL](mention://member/UUID) so they render as styled mentions.
|
||||
*/
|
||||
function preprocessMentionShortcodes(text: string): string {
|
||||
if (!text.includes('[@ ')) return text
|
||||
return text.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match, attrString: string) => {
|
||||
const attrs: Record<string, string> = {}
|
||||
const re = /(\w+)="([^"]*)"/g
|
||||
let m
|
||||
while ((m = re.exec(attrString)) !== null) {
|
||||
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2]
|
||||
}
|
||||
const { id, label } = attrs
|
||||
if (!id || !label) return match
|
||||
return `[@${label}](mention://member/${id})`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// File path detection regex - matches paths starting with /, ~/, or ./
|
||||
const FILE_PATH_REGEX =
|
||||
|
||||
@@ -2,3 +2,4 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from
|
||||
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
|
||||
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
|
||||
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
|
||||
export { preprocessMentionShortcodes } from './mentions'
|
||||
|
||||
25
apps/web/components/markdown/mentions.ts
Normal file
25
apps/web/components/markdown/mentions.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to the
|
||||
* standard markdown link format [@LABEL](mention://member/UUID).
|
||||
*
|
||||
* These shortcodes exist in older database records from a previous mention
|
||||
* serialization format. This function normalises them so downstream parsers
|
||||
* (Tiptap @tiptap/markdown, react-markdown) only need to handle one syntax.
|
||||
*/
|
||||
export function preprocessMentionShortcodes(text: string): string {
|
||||
if (!text.includes("[@ ")) return text;
|
||||
return text.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match, attrString: string) => {
|
||||
const attrs: Record<string, string> = {};
|
||||
const re = /(\w+)="([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrString)) !== null) {
|
||||
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
|
||||
}
|
||||
const { id, label } = attrs;
|
||||
if (!id || !label) return match;
|
||||
return `[@${label}](mention://member/${id})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
389
apps/web/features/editor/content-editor.css
Normal file
389
apps/web/features/editor/content-editor.css
Normal file
@@ -0,0 +1,389 @@
|
||||
/*
|
||||
* ContentEditor typography — ProseMirror styles using shadcn design tokens.
|
||||
*
|
||||
* Design tier: "Compact" (same tier as Linear, Slack). Optimized for short-form
|
||||
* content (issue descriptions, comments) that users scan, not long-form reading.
|
||||
*
|
||||
* Typography values benchmarked against (April 2026):
|
||||
* - github-markdown-css (GitHub's markdown renderer)
|
||||
* - @tailwindcss/typography prose-sm preset
|
||||
* - Linear's editor (Tiptap-based, 14px body)
|
||||
*
|
||||
* Key decisions:
|
||||
* Body: 14px (text-sm), line-height 1.625 (between GitHub 1.5 and Tailwind 1.714)
|
||||
* Headings: h1=22px (1.57x), h2=18px (1.29x), h3=15px (1.07x) — compact but
|
||||
* with clear hierarchy. Previous h3 was 14px (same as body = no differentiation).
|
||||
* Paragraph spacing: 10px (was 8px; GitHub uses 10px, Tailwind prose-sm uses 16px)
|
||||
* List indent: 20px for ul (was 16px; standard is 22-32px)
|
||||
* Code block margin: 12px (was 8px; gives breathing room between code and prose)
|
||||
* Blockquote border: 3px (was 2px; GitHub/Tailwind both use 4px)
|
||||
* Links: var(--brand) blue with 40% opacity underline (was var(--primary) near-black)
|
||||
*
|
||||
* Inline elements (mention cards, inline code) that exceed line-height:
|
||||
* The browser auto-expands the line box for lines containing taller inline
|
||||
* elements. Controlled via vertical-align on [data-node-view-wrapper] and
|
||||
* box-decoration-break: clone on inline code.
|
||||
*/
|
||||
|
||||
.rich-text-editor.ProseMirror {
|
||||
color: var(--foreground);
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
.rich-text-editor.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.rich-text-editor .is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Headings — compact but with clear visual hierarchy */
|
||||
.rich-text-editor h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rich-text-editor h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.rich-text-editor h3 {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
.rich-text-editor p {
|
||||
margin-top: 0.625rem;
|
||||
margin-bottom: 0.625rem;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
/* First child should not have top margin */
|
||||
.rich-text-editor > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Last child should not have bottom margin */
|
||||
.rich-text-editor > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.rich-text-editor ul {
|
||||
list-style-type: disc;
|
||||
padding-inline-start: 1.25rem;
|
||||
padding-inline-end: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor ol {
|
||||
list-style-type: decimal;
|
||||
padding-inline-start: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor li {
|
||||
margin: 0.25rem 0;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.rich-text-editor li + li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.rich-text-editor li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Remove paragraph margins inside list items (Tiptap wraps li content in <p>) */
|
||||
.rich-text-editor li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor li > p + p {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Nested lists — bullet style progression and tighter spacing */
|
||||
.rich-text-editor ul ul {
|
||||
list-style-type: circle;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.rich-text-editor ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.rich-text-editor code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.875rem;
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 75%, transparent);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.rich-text-editor pre {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.75rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rich-text-editor pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Syntax highlighting — lowlight (hljs) */
|
||||
.rich-text-editor .hljs-keyword,
|
||||
.rich-text-editor .hljs-selector-tag,
|
||||
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
|
||||
|
||||
.rich-text-editor .hljs-string,
|
||||
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
|
||||
|
||||
.rich-text-editor .hljs-comment,
|
||||
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
|
||||
|
||||
.rich-text-editor .hljs-number,
|
||||
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
|
||||
|
||||
.rich-text-editor .hljs-title,
|
||||
.rich-text-editor .hljs-section,
|
||||
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
|
||||
|
||||
.rich-text-editor .hljs-attr,
|
||||
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
|
||||
|
||||
.rich-text-editor .hljs-variable,
|
||||
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
|
||||
|
||||
.rich-text-editor .hljs-type,
|
||||
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
|
||||
|
||||
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
|
||||
|
||||
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .rich-text-editor .hljs-keyword,
|
||||
.dark .rich-text-editor .hljs-selector-tag,
|
||||
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
|
||||
|
||||
.dark .rich-text-editor .hljs-string,
|
||||
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
|
||||
|
||||
.dark .rich-text-editor .hljs-number,
|
||||
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
|
||||
|
||||
.dark .rich-text-editor .hljs-title,
|
||||
.dark .rich-text-editor .hljs-section,
|
||||
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
|
||||
|
||||
.dark .rich-text-editor .hljs-attr,
|
||||
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
|
||||
|
||||
.dark .rich-text-editor .hljs-variable,
|
||||
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
|
||||
|
||||
.dark .rich-text-editor .hljs-type,
|
||||
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
|
||||
|
||||
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
|
||||
|
||||
/* Tables */
|
||||
.rich-text-editor .tableWrapper {
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.rich-text-editor table {
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.rich-text-editor colgroup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rich-text-editor thead {
|
||||
background: color-mix(in srgb, var(--muted) 50%, transparent);
|
||||
}
|
||||
|
||||
.rich-text-editor tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rich-text-editor tr:hover td {
|
||||
background: color-mix(in srgb, var(--muted) 30%, transparent);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.rich-text-editor th,
|
||||
.rich-text-editor td {
|
||||
text-align: left;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rich-text-editor th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Remove paragraph margin inside table cells */
|
||||
.rich-text-editor th p,
|
||||
.rich-text-editor td p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.rich-text-editor blockquote {
|
||||
border-left: 3px solid color-mix(in srgb, var(--muted-foreground) 30%, transparent);
|
||||
padding-left: 0.75rem;
|
||||
margin: 0.625rem 0;
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-editor blockquote p {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.rich-text-editor blockquote > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor blockquote > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor blockquote blockquote {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-left-color: color-mix(in srgb, var(--muted-foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
.rich-text-editor hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.rich-text-editor a {
|
||||
color: var(--brand);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, var(--brand) 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rich-text-editor a:hover {
|
||||
text-decoration-color: var(--brand);
|
||||
}
|
||||
|
||||
/* Issue mention cards — inline cards that sit within text flow */
|
||||
.rich-text-editor a.issue-mention {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rich-text-editor a.issue-mention:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Mentions */
|
||||
.rich-text-editor .mention {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
/* Strong / emphasis */
|
||||
.rich-text-editor strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-text-editor em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-editor s {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Readonly mode overrides */
|
||||
.rich-text-editor.readonly.ProseMirror {
|
||||
caret-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Mention NodeView inline layout fix */
|
||||
.rich-text-editor [data-node-view-wrapper] {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Images — shared styling for both editing and readonly */
|
||||
.rich-text-editor img {
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Uploading image placeholder — data-uploading attribute managed by ProseMirror schema */
|
||||
.rich-text-editor img[data-uploading] {
|
||||
opacity: 0.5;
|
||||
border-radius: var(--radius);
|
||||
animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes rte-upload-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
198
apps/web/features/editor/content-editor.tsx
Normal file
198
apps/web/features/editor/content-editor.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ContentEditor — the single rich-text editor for the entire application.
|
||||
*
|
||||
* Architecture decisions (April 2026 refactor):
|
||||
*
|
||||
* 1. ONE COMPONENT for both editing and readonly display. The `editable` prop
|
||||
* controls the mode. Previously we had RichTextEditor + ReadonlyEditor as
|
||||
* separate components with duplicated extension configs — this caused
|
||||
* visual inconsistency between edit and display modes.
|
||||
*
|
||||
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
|
||||
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
|
||||
* Previously we had a custom `markdownToHtml()` pipeline (Marked library)
|
||||
* for loading and regex post-processing for saving — two asymmetric paths
|
||||
* that caused roundtrip inconsistencies. The @tiptap/markdown extension
|
||||
* (v3.21.0+) handles table cell <p> wrapping and custom mention tokenizers
|
||||
* natively, eliminating the need for the HTML detour.
|
||||
*
|
||||
* 3. PREPROCESSING is minimal: only legacy mention shortcode migration and
|
||||
* URL linkification (preprocessMarkdown). No HTML conversion.
|
||||
*
|
||||
* Tech: Tiptap v3.22.1 (ProseMirror wrapper), @tiptap/markdown for
|
||||
* bidirectional Markdown ↔ ProseMirror JSON conversion.
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { createEditorExtensions } from "./extensions";
|
||||
import { uploadAndInsertFile } from "./extensions/file-upload";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import "./content-editor.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ContentEditorProps {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
className?: string;
|
||||
debounceMs?: number;
|
||||
onSubmit?: () => void;
|
||||
onBlur?: () => void;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
}
|
||||
|
||||
interface ContentEditorRef {
|
||||
getMarkdown: () => string;
|
||||
clearContent: () => void;
|
||||
focus: () => void;
|
||||
uploadFile: (file: File) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
function ContentEditor(
|
||||
{
|
||||
defaultValue = "",
|
||||
onUpdate,
|
||||
placeholder: placeholderText = "",
|
||||
editable = true,
|
||||
className,
|
||||
debounceMs = 300,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onUploadFile,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
const onBlurRef = useRef(onBlur);
|
||||
const onUploadFileRef = useRef(onUploadFile);
|
||||
const prevContentRef = useRef(defaultValue);
|
||||
|
||||
// Keep refs in sync without recreating editor
|
||||
onUpdateRef.current = onUpdate;
|
||||
onSubmitRef.current = onSubmit;
|
||||
onBlurRef.current = onBlur;
|
||||
onUploadFileRef.current = onUploadFile;
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable,
|
||||
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
|
||||
contentType: defaultValue ? "markdown" : undefined,
|
||||
extensions: createEditorExtensions({
|
||||
editable,
|
||||
placeholder: placeholderText,
|
||||
onSubmitRef,
|
||||
onUploadFileRef,
|
||||
}),
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
if (!onUpdateRef.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdateRef.current?.(ed.getMarkdown());
|
||||
}, debounceMs);
|
||||
},
|
||||
onBlur: () => {
|
||||
onBlurRef.current?.();
|
||||
},
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
click(_view, event) {
|
||||
const target = event.target as HTMLElement;
|
||||
// Skip links inside NodeView wrappers — they handle their own clicks
|
||||
if (target.closest("[data-node-view-wrapper]")) return false;
|
||||
|
||||
const link = target.closest("a");
|
||||
const href = link?.getAttribute("href");
|
||||
if (!href || href.startsWith("mention://")) return false;
|
||||
|
||||
if (!editable) {
|
||||
// Readonly: any click on link opens new tab
|
||||
event.preventDefault();
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
// Edit mode: Cmd/Ctrl+click opens link
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
class: cn(
|
||||
"rich-text-editor text-sm outline-none",
|
||||
!editable && "readonly",
|
||||
className,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Readonly content update: when defaultValue changes and editor is readonly,
|
||||
// re-set the content (e.g. after editing a comment, the readonly view updates)
|
||||
useEffect(() => {
|
||||
if (!editor || editable) return;
|
||||
if (defaultValue === prevContentRef.current) return;
|
||||
prevContentRef.current = defaultValue;
|
||||
const processed = defaultValue ? preprocessMarkdown(defaultValue) : "";
|
||||
if (processed) {
|
||||
editor.commands.setContent(processed, { contentType: "markdown" });
|
||||
} else {
|
||||
editor.commands.clearContent();
|
||||
}
|
||||
}, [editor, editable, defaultValue]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => editor?.getMarkdown() ?? "",
|
||||
clearContent: () => {
|
||||
editor?.commands.clearContent();
|
||||
},
|
||||
focus: () => {
|
||||
editor?.commands.focus();
|
||||
},
|
||||
uploadFile: (file: File) => {
|
||||
if (!editor || !onUploadFileRef.current) return;
|
||||
const endPos = editor.state.doc.content.size;
|
||||
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
|
||||
},
|
||||
}));
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
},
|
||||
);
|
||||
|
||||
export { ContentEditor, type ContentEditorProps, type ContentEditorRef };
|
||||
119
apps/web/features/editor/extensions/file-upload.ts
Normal file
119
apps/web/features/editor/extensions/file-upload.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function removeImageBySrc(editor: any, src: string) {
|
||||
if (!editor) return;
|
||||
const { tr } = editor.state;
|
||||
let deleted = false;
|
||||
editor.state.doc.descendants((node: any, pos: number) => {
|
||||
if (deleted) return false;
|
||||
if (node.type.name === "image" && node.attrs.src === src) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
deleted = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (deleted) editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared upload flow: insert blob preview → upload → replace with real URL.
|
||||
* Used by both paste/drop (at cursor) and button upload (at end of doc).
|
||||
*/
|
||||
export async function uploadAndInsertFile(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editor: any,
|
||||
file: File,
|
||||
handler: (file: File) => Promise<UploadResult | null>,
|
||||
pos?: number,
|
||||
) {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
|
||||
if (isImage) {
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
|
||||
if (pos !== undefined) {
|
||||
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
|
||||
} else {
|
||||
editor.chain().focus().setImage(imgAttrs).run();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (result) {
|
||||
const { tr } = editor.state;
|
||||
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
|
||||
if (node.type.name === "image" && node.attrs.src === blobUrl) {
|
||||
tr.setNodeMarkup(nodePos, undefined, {
|
||||
...node.attrs,
|
||||
src: result.link,
|
||||
alt: result.filename,
|
||||
uploading: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
} else {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
}
|
||||
} catch {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
} else {
|
||||
// Non-image: upload first, then insert link
|
||||
const result = await handler(file);
|
||||
if (!result) return;
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
if (pos !== undefined) {
|
||||
editor.chain().focus().insertContentAt(pos, linkText).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(linkText).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createFileUploadExtension(
|
||||
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "fileUpload",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
const handleFiles = async (files: FileList) => {
|
||||
const handler = onUploadFileRef.current;
|
||||
if (!handler) return false;
|
||||
for (const file of Array.from(files)) {
|
||||
await uploadAndInsertFile(editor, file, handler);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("fileUpload"),
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const files = event.clipboardData?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
handleDrop(_view, event) {
|
||||
const files = (event as DragEvent).dataTransfer?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
125
apps/web/features/editor/extensions/index.ts
Normal file
125
apps/web/features/editor/extensions/index.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Shared extension factory for ContentEditor.
|
||||
*
|
||||
* One function builds the extension array for BOTH edit and readonly modes.
|
||||
* This ensures visual consistency — the same extensions parse and render
|
||||
* content identically regardless of mode.
|
||||
*
|
||||
* Split:
|
||||
* - Both modes: StarterKit, CodeBlock, Link, Image, Table, Markdown, Mention
|
||||
* - Edit only: Typography, Placeholder, markdownPaste, submitShortcut,
|
||||
* fileUpload, Mention suggestion popup
|
||||
*
|
||||
* Link config differs: edit mode has autolink (detects URLs while typing),
|
||||
* readonly does not (prevents false positives on display).
|
||||
*
|
||||
* Mention suggestion is only attached in edit mode — readonly doesn't need
|
||||
* the autocomplete popup.
|
||||
*
|
||||
* All link styling is controlled by content-editor.css (var(--brand) color),
|
||||
* not Tailwind HTMLAttributes, to keep a single source of truth.
|
||||
*/
|
||||
import type { RefObject } from "react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { BaseMentionExtension } from "./mention-extension";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
import { createMarkdownPasteExtension } from "./markdown-paste";
|
||||
import { createSubmitExtension } from "./submit-shortcut";
|
||||
import { createFileUploadExtension } from "./file-upload";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
const LinkEditable = Link.extend({ inclusive: false }).configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
linkOnPaste: false,
|
||||
});
|
||||
|
||||
const LinkReadonly = Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: false,
|
||||
});
|
||||
|
||||
const ImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
uploading: {
|
||||
default: false,
|
||||
renderHTML: (attrs: Record<string, unknown>) =>
|
||||
attrs.uploading ? { "data-uploading": "" } : {},
|
||||
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
|
||||
});
|
||||
|
||||
export interface EditorExtensionsOptions {
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
onSubmitRef?: RefObject<(() => void) | undefined>;
|
||||
onUploadFileRef?: RefObject<
|
||||
((file: File) => Promise<UploadResult | null>) | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export function createEditorExtensions(
|
||||
options: EditorExtensionsOptions,
|
||||
): AnyExtension[] {
|
||||
const { editable, placeholder: placeholderText } = options;
|
||||
|
||||
const extensions: AnyExtension[] = [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
link: false,
|
||||
codeBlock: false,
|
||||
}),
|
||||
CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockView);
|
||||
},
|
||||
}).configure({ lowlight }),
|
||||
editable ? LinkEditable : LinkReadonly,
|
||||
ImageExtension,
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown,
|
||||
BaseMentionExtension.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
...(editable ? { suggestion: createMentionSuggestion() } : {}),
|
||||
}),
|
||||
];
|
||||
|
||||
if (editable) {
|
||||
extensions.push(
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: placeholderText }),
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(() => options.onSubmitRef?.current?.()),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
67
apps/web/features/editor/extensions/markdown-paste.ts
Normal file
67
apps/web/features/editor/extensions/markdown-paste.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Markdown paste extension — ensures pasted text is parsed as Markdown.
|
||||
*
|
||||
* Problem: The browser clipboard can contain BOTH text/plain and text/html.
|
||||
* ProseMirror always prefers text/html when present (hardcoded in
|
||||
* parseFromClipboard: `let asText = !html`). When copying from VS Code,
|
||||
* text editors, or .md files, the OS wraps text in <pre>/<div> HTML tags.
|
||||
* ProseMirror parses these as code blocks — wrong.
|
||||
*
|
||||
* Solution: Use `handlePaste` (the only ProseMirror prop that runs for ALL
|
||||
* paste events and has access to raw ClipboardEvent). We check for
|
||||
* `data-pm-slice` in the HTML — this attribute is added by ProseMirror's
|
||||
* own clipboard serializer. If present, the source is another ProseMirror
|
||||
* editor and its HTML is structurally correct — let ProseMirror handle it.
|
||||
* Otherwise, ignore the HTML and parse text/plain as Markdown.
|
||||
*
|
||||
* Why not clipboardTextParser? It only runs when there's NO text/html on
|
||||
* the clipboard (ProseMirror source: `let asText = !!text && !html`).
|
||||
*
|
||||
* Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable.
|
||||
* VS Code's HTML contains <code> tags that fool rich-content detectors.
|
||||
* Markdown pattern matching has too many edge cases. The data-pm-slice
|
||||
* check is deterministic — no false positives.
|
||||
*/
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Slice } from "@tiptap/pm/model";
|
||||
|
||||
export function createMarkdownPasteExtension() {
|
||||
return Extension.create({
|
||||
name: "markdownPaste",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownPaste"),
|
||||
props: {
|
||||
handlePaste(view, event) {
|
||||
if (!editor.markdown) return false;
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard) return false;
|
||||
|
||||
const text = clipboard.getData("text/plain");
|
||||
if (!text) return false;
|
||||
|
||||
const html = clipboard.getData("text/html");
|
||||
|
||||
// If HTML contains data-pm-slice, the source is another
|
||||
// ProseMirror editor — let ProseMirror use its native HTML
|
||||
// clipboard path to preserve exact node structure.
|
||||
if (html && html.includes("data-pm-slice")) return false;
|
||||
|
||||
// Everything else (VS Code, text editors, .md files, terminals,
|
||||
// web pages): parse text/plain as Markdown.
|
||||
const json = editor.markdown.parse(text);
|
||||
const node = editor.schema.nodeFromJSON(json);
|
||||
const slice = Slice.maxOpen(node.content);
|
||||
const tr = view.state.tr.replaceSelection(slice);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
64
apps/web/features/editor/extensions/mention-extension.ts
Normal file
64
apps/web/features/editor/extensions/mention-extension.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { MentionView } from "./mention-view";
|
||||
|
||||
export const BaseMentionExtension = Mention.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const type = node.attrs.type ?? "member";
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
{ "data-type": "mention" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-mention-type": node.attrs.type ?? "member",
|
||||
"data-mention-id": node.attrs.id,
|
||||
},
|
||||
),
|
||||
`${prefix}${node.attrs.label ?? node.attrs.id}`,
|
||||
];
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
type: {
|
||||
default: "member",
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-mention-type") ?? "member",
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
markdownTokenizer: {
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = src.match(
|
||||
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
parseMarkdown: (token: any, helpers: any) => {
|
||||
return helpers.createNode("mention", token.attributes);
|
||||
},
|
||||
renderMarkdown: (node: any) => {
|
||||
const { id, label, type = "member" } = node.attrs || {};
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
|
||||
},
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Hash, Users } from "lucide-react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -24,8 +26,10 @@ export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "member" | "agent" | "issue" | "all";
|
||||
/** Secondary text shown below the label (e.g. issue title) */
|
||||
/** Secondary text shown beside the label (e.g. issue title) */
|
||||
description?: string;
|
||||
/** Issue status for StatusIcon rendering */
|
||||
status?: IssueStatus;
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
@@ -37,6 +41,33 @@ export interface MentionListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group items by section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MentionGroup {
|
||||
label: string;
|
||||
items: MentionItem[];
|
||||
}
|
||||
|
||||
function groupItems(items: MentionItem[]): MentionGroup[] {
|
||||
const users: MentionItem[] = [];
|
||||
const issues: MentionItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "issue") {
|
||||
issues.push(item);
|
||||
} else {
|
||||
users.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: MentionGroup[] = [];
|
||||
if (users.length > 0) groups.push({ label: "Users", items: users });
|
||||
if (issues.length > 0) groups.push({ label: "Issues", items: issues });
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionList — the popup rendered inside the editor
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -88,45 +119,93 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupItems(items);
|
||||
|
||||
// Build a flat index mapping: globalIndex → item
|
||||
let globalIndex = 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-popover py-1 shadow-md min-w-[180px] max-h-[240px] overflow-y-auto">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
key={`${item.type}-${item.id}`}
|
||||
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm transition-colors ${
|
||||
index === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
{item.type === "all" ? (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Users className="h-3 w-3" />
|
||||
</span>
|
||||
) : item.type === "issue" ? (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Hash className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<ActorAvatar
|
||||
actorType={item.type}
|
||||
actorId={item.id}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</div>
|
||||
</button>
|
||||
{group.items.map((item) => {
|
||||
const idx = globalIndex++;
|
||||
return (
|
||||
<MentionRow
|
||||
key={`${item.type}-${item.id}`}
|
||||
item={item}
|
||||
selected={idx === selectedIndex}
|
||||
onSelect={() => selectItem(idx)}
|
||||
buttonRef={(el) => { itemRefs.current[idx] = el; }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionRow — single item in the list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MentionRow({
|
||||
item,
|
||||
selected,
|
||||
onSelect,
|
||||
buttonRef,
|
||||
}: {
|
||||
item: MentionItem;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
buttonRef: (el: HTMLButtonElement | null) => void;
|
||||
}) {
|
||||
if (item.type === "issue") {
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
selected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{item.status && (
|
||||
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
selected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<ActorAvatar
|
||||
actorType={item.type === "all" ? "member" : item.type}
|
||||
actorId={item.id}
|
||||
size={20}
|
||||
/>
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.type === "agent" && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1.5">Agent</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suggestion config factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -144,7 +223,7 @@ export function createMentionSuggestion(): Omit<
|
||||
// Show "All members" option when query is empty or matches "all"
|
||||
const allItem: MentionItem[] =
|
||||
"all members".includes(q) || "all".includes(q)
|
||||
? [{ id: "all", label: "All members", type: "all" as const, description: "Notify all members" }]
|
||||
? [{ id: "all", label: "All members", type: "all" as const }]
|
||||
: [];
|
||||
|
||||
const memberItems: MentionItem[] = members
|
||||
@@ -156,7 +235,7 @@ export function createMentionSuggestion(): Omit<
|
||||
}));
|
||||
|
||||
const agentItems: MentionItem[] = agents
|
||||
.filter((a) => a.name.toLowerCase().includes(q))
|
||||
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
|
||||
const issueItems: MentionItem[] = issues
|
||||
@@ -170,6 +249,7 @@ export function createMentionSuggestion(): Omit<
|
||||
label: i.identifier,
|
||||
type: "issue" as const,
|
||||
description: i.title,
|
||||
status: i.status as IssueStatus,
|
||||
}));
|
||||
|
||||
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
79
apps/web/features/editor/extensions/mention-view.tsx
Normal file
79
apps/web/features/editor/extensions/mention-view.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MentionView — NodeView for rendering @mentions inline in the editor.
|
||||
*
|
||||
* Member/agent mentions: plain "@Name" text with .mention class styling.
|
||||
* Issue mentions: inline card with StatusIcon + identifier + title.
|
||||
*
|
||||
* Issue card sizing: must fit within the paragraph line box (14px * 1.625
|
||||
* = 22.75px). Card uses text-xs (12px) + py-0.5 + border ≈ 22px total.
|
||||
* vertical-align: middle is set on the [data-node-view-wrapper] in CSS
|
||||
* (not on the <a> tag) because the wrapper is the outermost inline element
|
||||
* that participates in line box calculation. Setting it on the inner <a>
|
||||
* had no effect since the wrapper was already positioned.
|
||||
*
|
||||
* Fallback: when issue is not in the Zustand store (deleted or other
|
||||
* workspace), the same card style is used with just the identifier from
|
||||
* fallbackLabel — no visual degradation to a plain text link.
|
||||
*/
|
||||
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
|
||||
export function MentionView({ node }: NodeViewProps) {
|
||||
const { type, id, label } = node.attrs;
|
||||
|
||||
if (type === "issue") {
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline">
|
||||
<IssueMention issueId={id} fallbackLabel={label} />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline">
|
||||
<span className="mention">@{label ?? id}</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueMention({
|
||||
issueId,
|
||||
fallbackLabel,
|
||||
}: {
|
||||
issueId: string;
|
||||
fallbackLabel?: string;
|
||||
}) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(`/issues/${issueId}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const cardClass =
|
||||
"issue-mention inline-flex items-center gap-1.5 rounded-md border mx-0.5 px-2 py-0.5 text-xs hover:bg-accent transition-colors cursor-pointer max-w-72";
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<a href={`/issues/${issueId}`} onClick={handleClick} className={cardClass}>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={`/issues/${issueId}`} onClick={handleClick} className={cardClass}>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="font-medium text-muted-foreground shrink-0">{issue.identifier}</span>
|
||||
<span className="text-foreground truncate">{issue.title}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
15
apps/web/features/editor/extensions/submit-shortcut.ts
Normal file
15
apps/web/features/editor/extensions/submit-shortcut.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export function createSubmitExtension(onSubmit: () => void) {
|
||||
return Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": () => {
|
||||
onSubmit();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
11
apps/web/features/editor/index.ts
Normal file
11
apps/web/features/editor/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
ContentEditor,
|
||||
type ContentEditorProps,
|
||||
type ContentEditorRef,
|
||||
} from "./content-editor";
|
||||
export {
|
||||
TitleEditor,
|
||||
type TitleEditorProps,
|
||||
type TitleEditorRef,
|
||||
} from "./title-editor";
|
||||
export { copyMarkdown } from "./utils/clipboard";
|
||||
6
apps/web/features/editor/utils/clipboard.ts
Normal file
6
apps/web/features/editor/utils/clipboard.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copy markdown content to the clipboard.
|
||||
*/
|
||||
export async function copyMarkdown(markdown: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
}
|
||||
24
apps/web/features/editor/utils/preprocess.ts
Normal file
24
apps/web/features/editor/utils/preprocess.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { preprocessLinks } from "@/components/markdown/linkify";
|
||||
import { preprocessMentionShortcodes } from "@/components/markdown/mentions";
|
||||
|
||||
/**
|
||||
* Preprocess a markdown string before loading into Tiptap via contentType: 'markdown'.
|
||||
*
|
||||
* This is the ONLY transform applied before @tiptap/markdown parses the content.
|
||||
* It does NOT convert to HTML — that was the old markdownToHtml.ts pipeline which
|
||||
* was deleted in the April 2026 refactor.
|
||||
*
|
||||
* Two string→string transforms on raw Markdown:
|
||||
* 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id)
|
||||
* (old serialization format in database, migrated on read)
|
||||
* 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes)
|
||||
*
|
||||
* After this, @tiptap/markdown's parse() handles everything else: headings, lists,
|
||||
* tables, code blocks, and our custom mention tokenizer ([@Name](mention://type/id)).
|
||||
*/
|
||||
export function preprocessMarkdown(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
const step1 = preprocessMentionShortcodes(markdown);
|
||||
const step2 = preprocessLinks(step1);
|
||||
return step2;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { InboxItem, IssueStatus } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
@@ -72,6 +73,7 @@ export const useInboxStore = create<InboxState>((set, get) => ({
|
||||
set({ items: data, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load inbox");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
@@ -88,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)),
|
||||
|
||||
@@ -1,12 +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";
|
||||
@@ -98,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
|
||||
@@ -123,10 +129,10 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
setItems(timeline);
|
||||
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch(console.error);
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [issueId]);
|
||||
@@ -207,19 +213,43 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch(console.error);
|
||||
}, [issueId, activeTask]),
|
||||
);
|
||||
|
||||
// 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) {
|
||||
@@ -238,7 +268,8 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
setCancelling(true);
|
||||
try {
|
||||
await api.cancelTask(issueId, activeTask.id);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
|
||||
setCancelling(false);
|
||||
}
|
||||
}, [activeTask, issueId, cancelling]);
|
||||
@@ -246,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,7 +389,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
|
||||
}, [issueId]);
|
||||
|
||||
// Refresh when a task completes
|
||||
@@ -330,7 +398,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskCompletedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
@@ -339,7 +407,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskFailedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
@@ -349,7 +417,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskCancelledPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
@@ -382,7 +450,10 @@ function TaskRunEntry({ task }: { task: AgentTask }) {
|
||||
if (items !== null) return; // already loaded
|
||||
api.listTaskMessages(task.id).then((msgs) => {
|
||||
setItems(buildTimeline(msgs));
|
||||
}).catch(() => setItems([]));
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
setItems([]);
|
||||
});
|
||||
}, [task.id, items]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function BatchActionToolbar() {
|
||||
toast.error("Failed to update issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
}).catch(console.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export function BatchActionToolbar() {
|
||||
toast.error("Failed to delete issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
}).catch(console.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteOpen(false);
|
||||
|
||||
@@ -13,6 +13,16 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||
@@ -20,8 +30,7 @@ import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { Markdown } from "@/components/markdown/Markdown";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
@@ -44,6 +53,43 @@ interface CommentCardProps {
|
||||
highlightedCommentId?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared delete confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DeleteCommentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
hasReplies,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
hasReplies?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete comment</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{hasReplies
|
||||
? "This comment and all its replies will be permanently deleted. This cannot be undone."
|
||||
: "This comment will be permanently deleted. This cannot be undone."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single comment row (used for both parent and replies within the same Card)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -65,12 +111,13 @@ function CommentRow({
|
||||
}) {
|
||||
const { getActorName } = useActorName();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const editEditorRef = useRef<RichTextEditorRef>(null);
|
||||
const editEditorRef = useRef<ContentEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
@@ -138,7 +185,7 @@ function CommentRow({
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(entry.content ?? "");
|
||||
copyMarkdown(entry.content ?? "");
|
||||
toast.success("Copied");
|
||||
}}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
@@ -152,7 +199,7 @@ function CommentRow({
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -160,6 +207,11 @@ function CommentRow({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteCommentDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
onConfirm={() => onDelete(entry.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -170,19 +222,19 @@ function CommentRow({
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={entry.content ?? ""}
|
||||
placeholder="Edit comment..."
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={(file) => uploadWithToast(file, { issueId })}
|
||||
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
||||
@@ -193,7 +245,7 @@ function CommentRow({
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
||||
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
@@ -229,11 +281,12 @@ function CommentCard({
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const editEditorRef = useRef<RichTextEditorRef>(null);
|
||||
const editEditorRef = useRef<ContentEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
@@ -333,7 +386,7 @@ function CommentCard({
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(entry.content ?? "");
|
||||
copyMarkdown(entry.content ?? "");
|
||||
toast.success("Copied");
|
||||
}}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
@@ -347,7 +400,7 @@ function CommentCard({
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -355,6 +408,12 @@ function CommentCard({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteCommentDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
onConfirm={() => onDelete(entry.id)}
|
||||
hasReplies
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -370,7 +429,7 @@ function CommentCard({
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={entry.content ?? ""}
|
||||
placeholder="Edit comment..."
|
||||
@@ -381,8 +440,7 @@ function CommentCard({
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={(file) => uploadWithToast(file, { issueId })}
|
||||
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
||||
@@ -393,7 +451,7 @@ function CommentCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
||||
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
@@ -13,16 +13,13 @@ interface CommentInputProps {
|
||||
}
|
||||
|
||||
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const attachmentIdsRef = useRef<string[]>([]);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) attachmentIdsRef.current.push(result.id);
|
||||
return result;
|
||||
return await uploadWithToast(file, { issueId });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -30,10 +27,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
await onSubmit(content);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -43,7 +38,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
return (
|
||||
<div className="relative flex max-h-56 flex-col rounded-lg bg-card pb-8 ring-1 ring-border">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
placeholder="Leave a comment..."
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
@@ -55,11 +50,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={handleUpload}
|
||||
onInsert={(result, isImage) =>
|
||||
editorRef.current?.insertFile(result.filename, result.link, isImage)
|
||||
}
|
||||
disabled={uploading}
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -43,9 +44,9 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||
import { RichTextEditor } from "@/components/common/rich-text-editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { TitleEditor } from "@/components/common/title-editor";
|
||||
import { TitleEditor } from "@/features/editor";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
@@ -197,6 +198,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const didHighlightRef = useRef<string | null>(null);
|
||||
|
||||
// Single source of truth: read issue directly from global store
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
|
||||
@@ -214,38 +216,40 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
.then((iss) => {
|
||||
useIssueStore.getState().addIssue(iss);
|
||||
})
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load issue");
|
||||
})
|
||||
.finally(() => setIssueLoading(false));
|
||||
}, [id, !!issue]);
|
||||
|
||||
// Custom hooks — encapsulate timeline, reactions, subscribers
|
||||
const {
|
||||
timeline, submitting, submitComment, submitReply,
|
||||
timeline, loading: timelineLoading, submitting, submitComment, submitReply,
|
||||
editComment, deleteComment, toggleReaction: handleToggleReaction,
|
||||
} = useIssueTimeline(id, user?.id);
|
||||
|
||||
const {
|
||||
reactions: issueReactions,
|
||||
reactions: issueReactions, loading: reactionsLoading,
|
||||
toggleReaction: handleToggleIssueReaction,
|
||||
} = useIssueReactions(id, user?.id);
|
||||
|
||||
const {
|
||||
subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||
} = useIssueSubscribers(id, user?.id);
|
||||
|
||||
const loading = issueLoading;
|
||||
|
||||
// Scroll to highlighted comment once timeline loads
|
||||
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
|
||||
useEffect(() => {
|
||||
if (!highlightCommentId || timeline.length === 0) return;
|
||||
// Find the comment element — could be a top-level comment or a reply
|
||||
if (didHighlightRef.current === highlightCommentId) return;
|
||||
const el = document.getElementById(`comment-${highlightCommentId}`);
|
||||
if (el) {
|
||||
// Small delay to ensure layout is settled
|
||||
didHighlightRef.current = highlightCommentId;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setHighlightedId(highlightCommentId);
|
||||
// Clear highlight after animation
|
||||
const timer = setTimeout(() => setHighlightedId(null), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
@@ -283,7 +287,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
[issue, id],
|
||||
);
|
||||
|
||||
const descEditorRef = useRef<import("@/components/common/rich-text-editor").RichTextEditorRef>(null);
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file, { issueId: id }),
|
||||
[uploadWithToast, id],
|
||||
@@ -305,8 +309,51 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Content skeleton */}
|
||||
<div className="flex-1 p-8 space-y-6">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
<Skeleton className="h-px w-full" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sidebar skeleton */}
|
||||
<div className="w-64 border-l p-4 space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-px w-full" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -466,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 })}
|
||||
@@ -594,7 +641,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
}}
|
||||
/>
|
||||
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
key={id}
|
||||
defaultValue={issue.description || ""}
|
||||
@@ -606,15 +653,21 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 mt-3">
|
||||
<ReactionBar
|
||||
reactions={issueReactions}
|
||||
currentUserId={user?.id}
|
||||
onToggle={handleToggleIssueReaction}
|
||||
/>
|
||||
{reactionsLoading ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-7 w-14 rounded-full" />
|
||||
<Skeleton className="h-7 w-14 rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ReactionBar
|
||||
reactions={issueReactions}
|
||||
currentUserId={user?.id}
|
||||
onToggle={handleToggleIssueReaction}
|
||||
/>
|
||||
)}
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={handleDescriptionUpload}
|
||||
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -627,6 +680,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
<h2 className="text-base font-semibold">Activity</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{subscribersLoading ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div className="flex -space-x-1">
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<button
|
||||
onClick={handleToggleSubscribe}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -680,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 (
|
||||
@@ -704,16 +766,16 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>)}
|
||||
</div>
|
||||
</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">
|
||||
@@ -722,7 +784,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
|
||||
{/* Timeline entries */}
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{(() => {
|
||||
{timelineLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 px-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (() => {
|
||||
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
|
||||
const repliesByParent = new Map<string, TimelineEntry[]>();
|
||||
for (const e of timeline) {
|
||||
@@ -773,9 +847,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
if (group.type === "comment") {
|
||||
const entry = group.entries[0]!;
|
||||
return (
|
||||
<div id={`comment-${entry.id}`}>
|
||||
<div key={entry.id} id={`comment-${entry.id}`}>
|
||||
<CommentCard
|
||||
key={entry.id}
|
||||
issueId={id}
|
||||
entry={entry}
|
||||
allReplies={repliesByParent}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, ListTodo } from "lucide-react";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
@@ -84,7 +84,7 @@ export function IssuesPage() {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
}).catch(console.error);
|
||||
});
|
||||
},
|
||||
[]
|
||||
@@ -131,19 +131,27 @@ export function IssuesPage() {
|
||||
|
||||
{/* Content: scrollable */}
|
||||
<ViewStoreProvider store={useIssueViewStore}>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
{scopedIssues.length === 0 ? (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">No issues yet</p>
|
||||
<p className="text-xs">Create an issue to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
</ViewStoreProvider>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function AssigneePicker({
|
||||
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: string, id: string) =>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
@@ -33,13 +33,12 @@ function ReplyInput({
|
||||
onSubmit,
|
||||
size = "default",
|
||||
}: ReplyInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const attachmentIdsRef = useRef<string[]>([]);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current;
|
||||
@@ -53,9 +52,7 @@ function ReplyInput({
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) attachmentIdsRef.current.push(result.id);
|
||||
return result;
|
||||
return await uploadWithToast(file, { issueId });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -63,10 +60,8 @@ function ReplyInput({
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
await onSubmit(content);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -92,7 +87,7 @@ function ReplyInput({
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-14">
|
||||
<div ref={measureRef}>
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
@@ -105,11 +100,7 @@ function ReplyInput({
|
||||
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={handleUpload}
|
||||
onInsert={(result, isImage) =>
|
||||
editorRef.current?.insertFile(result.filename, result.link, isImage)
|
||||
}
|
||||
disabled={uploading}
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -46,7 +46,7 @@ export const STATUS_CONFIG: Record<
|
||||
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" },
|
||||
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", badgeBg: "bg-warning", badgeText: "text-white", columnBg: "bg-warning/5" },
|
||||
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10", dividerColor: "bg-success", badgeBg: "bg-success", badgeText: "text-white", columnBg: "bg-success/5" },
|
||||
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", badgeBg: "bg-info", badgeText: "text-white", columnBg: "bg-info/5" },
|
||||
done: { label: "Done", iconColor: "text-done", hoverBg: "hover:bg-done/10", dividerColor: "bg-done", badgeBg: "bg-done", badgeText: "text-white", columnBg: "bg-done/5" },
|
||||
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", badgeBg: "bg-destructive", badgeText: "text-white", columnBg: "bg-destructive/5" },
|
||||
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" },
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
IssueReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueReactions(issueId: string, userId?: string) {
|
||||
const [reactions, setReactions] = useState<IssueReaction[]>([]);
|
||||
@@ -21,7 +21,10 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
||||
api
|
||||
.getIssue(issueId)
|
||||
.then((iss) => setReactions(iss.reactions ?? []))
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load reactions");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
SubscriberRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
|
||||
@@ -21,7 +21,10 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
api
|
||||
.listIssueSubscribers(issueId)
|
||||
.then((subs) => setSubscribers(subs))
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load subscribers");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
|
||||
@@ -41,7 +41,10 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
api
|
||||
.listTimeline(issueId)
|
||||
.then((entries) => setTimeline(entries))
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load activity");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
@@ -34,6 +35,7 @@ export const useIssueStore = create<IssueState>((set, get) => ({
|
||||
set({ issues: res.issues, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load issues");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { TitleEditor } from "@/components/common/title-editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { TitleEditor } from "@/features/editor";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
@@ -77,7 +77,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
|
||||
|
||||
const [title, setTitle] = useState(draft.title);
|
||||
const descEditorRef = useRef<RichTextEditorRef>(null);
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || draft.status);
|
||||
const [priority, setPriority] = useState<IssuePriority>(draft.priority);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -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
|
||||
@@ -231,7 +231,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
|
||||
{/* Description — takes remaining space */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue={draft.description}
|
||||
placeholder="Add description..."
|
||||
@@ -419,8 +419,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<FileUploadButton
|
||||
onUpload={handleUpload}
|
||||
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, ListTodo } from "lucide-react";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
@@ -124,7 +124,7 @@ export function MyIssuesPage() {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
}).catch(console.error);
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -171,19 +171,27 @@ export function MyIssuesPage() {
|
||||
|
||||
{/* Content: scrollable */}
|
||||
<ViewStoreProvider store={myIssuesViewStore}>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={myIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
{myIssues.length === 0 ? (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">No issues assigned to you</p>
|
||||
<p className="text-xs">Issues you create or are assigned to will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={myIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
</ViewStoreProvider>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
WorkspaceDeletedPayload,
|
||||
MemberRemovedPayload,
|
||||
IssueUpdatedPayload,
|
||||
IssueCreatedPayload,
|
||||
IssueDeletedPayload,
|
||||
InboxNewPayload,
|
||||
} from "@/shared/types";
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
@@ -34,8 +37,12 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
||||
// Event types handled by specific handlers below — skip generic refresh
|
||||
const specificEvents = new Set([
|
||||
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
||||
]);
|
||||
|
||||
const refreshMap: Record<string, () => void> = {
|
||||
issue: () => void useIssueStore.getState().fetch(),
|
||||
inbox: () => void useInboxStore.getState().fetch(),
|
||||
agent: () => void useWorkspaceStore.getState().refreshAgents(),
|
||||
member: () => void useWorkspaceStore.getState().refreshMembers(),
|
||||
@@ -74,21 +81,40 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
logger.debug("skipping self-event", msg.type);
|
||||
return;
|
||||
}
|
||||
if (specificEvents.has(msg.type)) return;
|
||||
const prefix = msg.type.split(":")[0] ?? "";
|
||||
const refresh = refreshMap[prefix];
|
||||
if (refresh) debouncedRefresh(prefix, refresh);
|
||||
});
|
||||
|
||||
// --- Side-effect handlers (toast, navigation, cross-store sync) ---
|
||||
// --- Specific event handlers (granular updates, no full refetch) ---
|
||||
|
||||
// Keep inbox issue_status in sync when issues change
|
||||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
if (issue?.id && issue?.status) {
|
||||
if (!issue?.id) return;
|
||||
useIssueStore.getState().updateIssue(issue.id, issue);
|
||||
if (issue.status) {
|
||||
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
||||
const { issue } = p as IssueCreatedPayload;
|
||||
if (issue) useIssueStore.getState().addIssue(issue);
|
||||
});
|
||||
|
||||
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
if (issue_id) useIssueStore.getState().removeIssue(issue_id);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (item) useInboxStore.getState().addItem(item);
|
||||
});
|
||||
|
||||
// --- Side-effect handlers (toast, navigation) ---
|
||||
|
||||
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
|
||||
const { workspace_id } = p as WorkspaceDeletedPayload;
|
||||
const currentWs = useWorkspaceStore.getState().workspace;
|
||||
@@ -123,6 +149,9 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
return () => {
|
||||
unsubAny();
|
||||
unsubIssueUpdated();
|
||||
unsubIssueCreated();
|
||||
unsubIssueDeleted();
|
||||
unsubInboxNew();
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
@@ -145,8 +174,8 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
useWorkspaceStore.getState().refreshMembers(),
|
||||
useWorkspaceStore.getState().refreshSkills(),
|
||||
]);
|
||||
} catch {
|
||||
// Silently fail; next reconnect will retry
|
||||
} catch (e) {
|
||||
logger.error("reconnect refetch failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
@@ -44,8 +45,36 @@ export default function RuntimesPage() {
|
||||
|
||||
if (isLoading || fetching) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* List skeleton */}
|
||||
<div className="w-72 border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Detail skeleton */}
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { RuntimeUsage } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { formatTokens, estimateCost, aggregateByDate } from "../utils";
|
||||
@@ -38,7 +39,22 @@ export function UsageSection({ runtimeId }: { runtimeId: string }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">Loading usage...</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-12 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-64 rounded-lg" />
|
||||
<Skeleton className="h-64 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
@@ -352,6 +354,7 @@ function SkillDetail({
|
||||
);
|
||||
const [selectedPath, setSelectedPath] = useState(SKILL_MD);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [showAddFile, setShowAddFile] = useState(false);
|
||||
|
||||
@@ -365,10 +368,13 @@ function SkillDetail({
|
||||
// Fetch full skill (with files) on selection change
|
||||
useEffect(() => {
|
||||
setSelectedPath(SKILL_MD);
|
||||
setLoadingFiles(true);
|
||||
api.getSkill(skill.id).then((full) => {
|
||||
useWorkspaceStore.getState().upsertSkill(full);
|
||||
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
|
||||
});
|
||||
}).catch((e) => {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to load skill files");
|
||||
}).finally(() => setLoadingFiles(false));
|
||||
}, [skill.id]);
|
||||
|
||||
// Build the virtual file map
|
||||
@@ -392,6 +398,8 @@ function SkillDetail({
|
||||
content,
|
||||
files: files.filter((f) => f.path.trim()),
|
||||
});
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -514,22 +522,40 @@ function SkillDetail({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<FileTree
|
||||
filePaths={filePaths}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
/>
|
||||
{loadingFiles ? (
|
||||
<div className="p-3 space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
) : (
|
||||
<FileTree
|
||||
filePaths={filePaths}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File viewer */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{loadingFiles ? (
|
||||
<div className="p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
<FileViewer
|
||||
key={selectedPath}
|
||||
path={selectedPath}
|
||||
content={selectedContent}
|
||||
onChange={handleFileContentChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -604,34 +630,83 @@ export default function SkillsPage() {
|
||||
const skill = await api.createSkill(data);
|
||||
upsertSkill(skill);
|
||||
setSelectedId(skill.id);
|
||||
toast.success("Skill created");
|
||||
};
|
||||
|
||||
const handleImport = async (url: string) => {
|
||||
const skill = await api.importSkill({ url });
|
||||
upsertSkill(skill);
|
||||
setSelectedId(skill.id);
|
||||
toast.success("Skill imported");
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
|
||||
const updated = await api.updateSkill(id, data);
|
||||
upsertSkill(updated);
|
||||
try {
|
||||
const updated = await api.updateSkill(id, data);
|
||||
upsertSkill(updated);
|
||||
toast.success("Skill saved");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save skill");
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api.deleteSkill(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = skills.filter((s) => s.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
try {
|
||||
await api.deleteSkill(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = skills.filter((s) => s.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
}
|
||||
removeSkill(id);
|
||||
toast.success("Skill deleted");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete skill");
|
||||
}
|
||||
removeSkill(id);
|
||||
};
|
||||
|
||||
const selected = skills.find((s) => s.id === selectedId) ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* List skeleton */}
|
||||
<div className="w-72 border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Detail skeleton */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-8 w-56" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-48 border-r p-3 space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
@@ -76,11 +77,19 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
|
||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
|
||||
api.listMembers(nextWorkspace.id),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id }),
|
||||
api.listMembers(nextWorkspace.id).catch((e) => {
|
||||
logger.error("failed to load members", e);
|
||||
toast.error("Failed to load members");
|
||||
return [] as MemberWithUser[];
|
||||
}),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => {
|
||||
logger.error("failed to load agents", e);
|
||||
toast.error("Failed to load agents");
|
||||
return [] as Agent[];
|
||||
}),
|
||||
api.listSkills().catch(() => [] as Skill[]),
|
||||
useIssueStore.getState().fetch(),
|
||||
useInboxStore.getState().fetch(),
|
||||
useIssueStore.getState().fetch().catch(() => {}),
|
||||
useInboxStore.getState().fetch().catch(() => {}),
|
||||
]);
|
||||
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
|
||||
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
|
||||
@@ -113,16 +122,27 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
refreshWorkspaces: async () => {
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||
return wsList;
|
||||
try {
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||
return wsList;
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh workspaces", e);
|
||||
toast.error("Failed to refresh workspaces");
|
||||
return get().workspaces;
|
||||
}
|
||||
},
|
||||
|
||||
refreshMembers: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
const members = await api.listMembers(workspace.id);
|
||||
set({ members });
|
||||
try {
|
||||
const members = await api.listMembers(workspace.id);
|
||||
set({ members });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh members", e);
|
||||
toast.error("Failed to refresh members");
|
||||
}
|
||||
},
|
||||
|
||||
updateAgent: (id, updates) =>
|
||||
@@ -133,23 +153,33 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
refreshAgents: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
const agents = await api.listAgents({ workspace_id: workspace.id });
|
||||
set({ agents });
|
||||
try {
|
||||
const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true });
|
||||
set({ agents });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh agents", e);
|
||||
toast.error("Failed to refresh agents");
|
||||
}
|
||||
},
|
||||
|
||||
refreshSkills: async () => {
|
||||
const { workspace, skills: existing } = get();
|
||||
if (!workspace) return;
|
||||
const fetched = await api.listSkills();
|
||||
// listSkills doesn't include files — preserve files from existing entries
|
||||
const filesById = new Map(
|
||||
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
|
||||
);
|
||||
const merged = fetched.map((s) => ({
|
||||
...s,
|
||||
files: s.files ?? filesById.get(s.id) ?? [],
|
||||
}));
|
||||
set({ skills: merged });
|
||||
try {
|
||||
const fetched = await api.listSkills();
|
||||
// listSkills doesn't include files — preserve files from existing entries
|
||||
const filesById = new Map(
|
||||
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
|
||||
);
|
||||
const merged = fetched.map((s) => ({
|
||||
...s,
|
||||
files: s.files ?? filesById.get(s.id) ?? [],
|
||||
}));
|
||||
set({ skills: merged });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh skills", e);
|
||||
toast.error("Failed to refresh skills");
|
||||
}
|
||||
},
|
||||
|
||||
upsertSkill: (skill) => {
|
||||
|
||||
@@ -18,16 +18,21 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@tiptap/extension-code-block-lowlight": "3.20.5",
|
||||
"@tiptap/extension-image": "^3.20.5",
|
||||
"@tiptap/extension-link": "^3.20.5",
|
||||
"@tiptap/extension-mention": "^3.20.5",
|
||||
"@tiptap/extension-placeholder": "^3.20.5",
|
||||
"@tiptap/extension-typography": "^3.20.5",
|
||||
"@tiptap/markdown": "^3.20.5",
|
||||
"@tiptap/pm": "^3.20.5",
|
||||
"@tiptap/react": "^3.20.5",
|
||||
"@tiptap/starter-kit": "^3.20.5",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.22.1",
|
||||
"@tiptap/extension-image": "^3.22.1",
|
||||
"@tiptap/extension-link": "^3.22.1",
|
||||
"@tiptap/extension-mention": "^3.22.1",
|
||||
"@tiptap/suggestion": "^3.22.1",
|
||||
"@tiptap/extension-placeholder": "^3.22.1",
|
||||
"@tiptap/extension-table": "^3.22.1",
|
||||
"@tiptap/extension-table-cell": "^3.22.1",
|
||||
"@tiptap/extension-table-header": "^3.22.1",
|
||||
"@tiptap/extension-table-row": "^3.22.1",
|
||||
"@tiptap/extension-typography": "^3.22.1",
|
||||
"@tiptap/markdown": "^3.22.1",
|
||||
"@tiptap/pm": "^3.22.1",
|
||||
"@tiptap/react": "^3.22.1",
|
||||
"@tiptap/starter-kit": "^3.22.1",
|
||||
"@types/linkify-it": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -291,10 +291,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// Agents
|
||||
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
|
||||
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
|
||||
const search = new URLSearchParams();
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.include_archived) search.set("include_archived", "true");
|
||||
return this.fetch(`/api/agents?${search}`);
|
||||
}
|
||||
|
||||
@@ -316,8 +317,12 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAgent(id: string): Promise<void> {
|
||||
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
|
||||
async archiveAgent(id: string): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}/archive`, { method: "POST" });
|
||||
}
|
||||
|
||||
async restoreAgent(id: string): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface Agent {
|
||||
triggers: AgentTrigger[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at: string | null;
|
||||
archived_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
|
||||
@@ -15,7 +15,8 @@ export type WSEventType =
|
||||
| "comment:deleted"
|
||||
| "agent:status"
|
||||
| "agent:created"
|
||||
| "agent:deleted"
|
||||
| "agent:archived"
|
||||
| "agent:restored"
|
||||
| "task:dispatch"
|
||||
| "task:progress"
|
||||
| "task:completed"
|
||||
@@ -71,9 +72,12 @@ export interface AgentCreatedPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface AgentDeletedPayload {
|
||||
agent_id: string;
|
||||
workspace_id: string;
|
||||
export interface AgentArchivedPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface AgentRestoredPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface InboxNewPayload {
|
||||
|
||||
@@ -62,6 +62,8 @@ export const mockAgents: Agent[] = [
|
||||
triggers: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
545
pnpm-lock.yaml
generated
545
pnpm-lock.yaml
generated
@@ -76,35 +76,50 @@ importers:
|
||||
specifier: ^1.7.6
|
||||
version: 1.7.6
|
||||
'@tiptap/extension-code-block-lowlight':
|
||||
specifier: 3.20.5
|
||||
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@tiptap/extension-image':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-mention':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@tiptap/suggestion@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(@tiptap/suggestion@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/extensions@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-table':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-table-cell':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/extension-table@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-table-header':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/extension-table@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-table-row':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/extension-table@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-typography':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/markdown':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1
|
||||
'@tiptap/react':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.20.5
|
||||
version: 3.20.5
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1
|
||||
'@tiptap/suggestion':
|
||||
specifier: ^3.22.1
|
||||
version: 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@types/linkify-it':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
@@ -1302,197 +1317,218 @@ packages:
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
|
||||
'@tiptap/core@3.20.5':
|
||||
resolution: {integrity: sha512-Pkjd41UJ4F6Z8cPV+gEvqnt1VhY2g66xMjbpxREs0ECA5jRezCNKSZcc2pueQRTMtmn1SaSzGM9U/ifhVlVYOA==}
|
||||
'@tiptap/core@3.22.1':
|
||||
resolution: {integrity: sha512-6wPNhkdLIGYiKAGqepDCRtR0TYGJxV40SwOEN2vlPhsXqAgzmyG37UyREj5pGH5xTekugqMCgCnyRg7m5nYoYQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/extension-blockquote@3.20.5':
|
||||
resolution: {integrity: sha512-0wU6H/MWWes0rGzgSW6MMU6YDs/3ofUDkqmqCqmb+Siu1ZD0bpzOYpBtujgOYDY8moB9+zCE3G9HSYGcmZxHew==}
|
||||
'@tiptap/extension-blockquote@3.22.1':
|
||||
resolution: {integrity: sha512-omPsJ/IMAZYhXqOaEenYE+HA9U2zju5rQbAn6Xktynvr4A5P95jqkgAwncXB82pCkNYU/uYxi51vyTweTeEUHA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-bold@3.20.5':
|
||||
resolution: {integrity: sha512-hraiiWkF58n8Jy0Wl3OGwjCTrGWwZZxez/IlexrzKQ/nMFdjDpensZucWwu59zhAM9fqZwGSLDtCFuak03WKnA==}
|
||||
'@tiptap/extension-bold@3.22.1':
|
||||
resolution: {integrity: sha512-0+q6Apu1Vx2+ReB2ktTpBrQ5/dCvGzTkJCy+MZ/t8WBcybqFXOKYRCr/i/VGPDpXZttxpk0EPl0+ao+NVcUTAA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.20.5':
|
||||
resolution: {integrity: sha512-6FsASu4o32bp3FzBVb5N2ERjrBy83DtJQAGv9/ycYqsgv2kq9DNlhvtNI7GPiTW7a73ZcImjIX+jEWrARbzOlQ==}
|
||||
'@tiptap/extension-bubble-menu@3.22.1':
|
||||
resolution: {integrity: sha512-JJI63N55hLPjfqHgBnbG1ORZTXJiswnfBkfNd8YKytCC8D++g5qX3UMObxmJKLMBRGyqjEi6krzOyYtOix5ALA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/extension-bullet-list@3.20.5':
|
||||
resolution: {integrity: sha512-MT3321R6F8AoVUEMJ5RiI0PQMenwvtmrSXoO1ehPCWq5TrSJLyXeZMJvZU+1CgfXk4XQU70RN78ib5+Zg+/FCg==}
|
||||
'@tiptap/extension-bullet-list@3.22.1':
|
||||
resolution: {integrity: sha512-83L+4N2JziWORbWtlsM0xBm3LOKIw4YtIm+Kh4amV5kGvIgIL5I1KYzoxv20qjgFX2k08LtLMwPdvPSPSh4e7g==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.5
|
||||
'@tiptap/extension-list': ^3.22.1
|
||||
|
||||
'@tiptap/extension-code-block-lowlight@3.20.5':
|
||||
resolution: {integrity: sha512-EINMkflwiUfCkBTAj1meP+nwEEUyXKmJF4yQVHzbt/iIswMtIc/7qvyld92VBgXWJkc+vo/lIPioaZGoSO7TsQ==}
|
||||
'@tiptap/extension-code-block-lowlight@3.22.1':
|
||||
resolution: {integrity: sha512-6Dj5AKGTi05EYqKJYS2NXpU72TQ8SVWOLDgnbsPDhoyl9hV4cnQ+1imnytfFrLX3wu5aOcKyk3tgV7BsNLIdvg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/extension-code-block': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/extension-code-block': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
highlight.js: ^11
|
||||
lowlight: ^2 || ^3
|
||||
|
||||
'@tiptap/extension-code-block@3.20.5':
|
||||
resolution: {integrity: sha512-0YZnqfqZ1IjzKBM4aezw8j3LZWJFEfs4+mbizHNlnZSYpKzpESYLeaLWGO5SpqF9Z8tmYmSoCaf0fqi5LwgdIA==}
|
||||
'@tiptap/extension-code-block@3.22.1':
|
||||
resolution: {integrity: sha512-fr3b1seFsAeYHtPAb9fbATkGcgyfStD05GHsZXFLh7yCpf2ejWLNxdWJT/g+FggSEHYFKCXT06aixk0WbtRcWw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/extension-code@3.20.5':
|
||||
resolution: {integrity: sha512-jBZK/CfdMvg1gkNK/zNAk02IExpBPwUfNLRPiJvGhReL2Q73naKxZGQGp+5Lej9VaeFB70UKuRma/iIzuZbgsA==}
|
||||
'@tiptap/extension-code@3.22.1':
|
||||
resolution: {integrity: sha512-Ze+hjSLLCn+5gVpuE/Uv7mQ83AlG5A9OPsuDoyzTpJ2XNvZP2iZdwQMGqwXKC8eH7fIOJN6XQ3IDv/EhltQx/Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-document@3.20.5':
|
||||
resolution: {integrity: sha512-BpNGHtOTAjjs/6QbkrafMTlaJqb0gsPngFzd5rB0csxx7rYRE9nIEY+oZ44qMw161+2YB4u20L17SX2mUJANBw==}
|
||||
'@tiptap/extension-document@3.22.1':
|
||||
resolution: {integrity: sha512-fBI/+PGtK6pzitqjSSSYL2+uZglX6T53zb5nLEmN/q8q7FzUuUpglp8toHVhBG05WDk4vx6Z7bC95uyxkYdoAA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-dropcursor@3.20.5':
|
||||
resolution: {integrity: sha512-/lDG9OjvAv0ynmgFH17mt/GUeGT5bqu0iPW8JMgaRqlKawk+uUIv5SF5WkXS4SwxXih+hXdPEQD3PWZnxlQxAQ==}
|
||||
'@tiptap/extension-dropcursor@3.22.1':
|
||||
resolution: {integrity: sha512-PuSNoTROZB564KpTG9ExVB3CsfRa0ridHx+1sWZajOBVZJiXSn4QlS/ShS509SOx8z17DyxEw06IH//OHY9XyQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.5
|
||||
'@tiptap/extensions': ^3.22.1
|
||||
|
||||
'@tiptap/extension-floating-menu@3.20.5':
|
||||
resolution: {integrity: sha512-mTzBNUeAocinrxa5xV+5hGnnNCQB0pVI1GSBwUTHwdB7jNwBqfKAILmtLZONgmhxKWLmGa6WCA59sk+yDI+N0A==}
|
||||
'@tiptap/extension-floating-menu@3.22.1':
|
||||
resolution: {integrity: sha512-TaZqmaoKv36FzbKTrBkkv74o0t8dYTftNZ7NotBqfSki0BB2PupTCJHafdu1YI0zmJ3xEzjB/XKcKPz2+10sDA==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/extension-gapcursor@3.20.5':
|
||||
resolution: {integrity: sha512-H+bRr+mqU/DQq1vfoMlppK1o+RbfSKYBMIcAMHWOez+C96MWfj5bhooVU2HLtl4XGmQxKGr3oEOCKDPdtRNThg==}
|
||||
'@tiptap/extension-gapcursor@3.22.1':
|
||||
resolution: {integrity: sha512-qqsyy7unWM3elv+7ru+6paKAnw1PZTvjNVQu3UzB6d556Gx2uE4isXJNdBaslBZdp2EoaYdIkhhEccW9B/Nwqg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.5
|
||||
'@tiptap/extensions': ^3.22.1
|
||||
|
||||
'@tiptap/extension-hard-break@3.20.5':
|
||||
resolution: {integrity: sha512-+aILNDO7BsXf0IJ4/0BYh570usFK3Q1t/ZQd8zhHuO2ATeWeDVu1x2F+ouFS4X8fmoCcioMzw15aoz93GET6kQ==}
|
||||
'@tiptap/extension-hard-break@3.22.1':
|
||||
resolution: {integrity: sha512-hzLwLEZVbZODa9q5UiCQpOUmDnyxN19FA4LhlqLP0/JSHewP/aol5igFZwuw0XVFp425BuzPjrB7tmr0GRTDWw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-heading@3.20.5':
|
||||
resolution: {integrity: sha512-zXxuIrCSpzgXzRxgCbRE8DZ/NFuinVaniE3pp/9LYAWgRlsAyko8pI2XrVvzzXmDQqRGi2HrNVkNy1yutUWSWQ==}
|
||||
'@tiptap/extension-heading@3.22.1':
|
||||
resolution: {integrity: sha512-EaIihzrOfXUHQlL6fFyJCkDrjgg0e/eD4jpkjhKpeuJDcqf7eJ1c0E2zcNRAiZkeXdN/hTQFaXKsSyNUE7T7Sg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.20.5':
|
||||
resolution: {integrity: sha512-4UtpUHg8cRzxWjJUGtni5VnXYbhsO7ygf1H1pr4Rv63XMBg9lfYDeSwByIuVy9biEFP7eGEFnezzb5Zlh1btmQ==}
|
||||
'@tiptap/extension-horizontal-rule@3.22.1':
|
||||
resolution: {integrity: sha512-Q18A8IN+gnfptIksPeVAI6oOBGYKAGf+QN0FEJ5OXO4BEAmA3hflflA1rWNfPC4aQNry/N7sAl8Gpd6HuIbz2w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/extension-image@3.20.5':
|
||||
resolution: {integrity: sha512-qxKupWKhX75Xc9GJ9Uel+KIFL9x6tb8W3RvQM1UolyJX/H7wyBO7sXp9XmKRkHZsDXRgLVbnkYBe+X83o16AIA==}
|
||||
'@tiptap/extension-image@3.22.1':
|
||||
resolution: {integrity: sha512-FtZCOWyyaEvSfaOPoH78IKb1BlG/Vao4PARdlrVCD1FlV1YGLAgSW5YkQAJ/vPTLwyNNZtqryaBpZrA8Wm25nQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-italic@3.20.5':
|
||||
resolution: {integrity: sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w==}
|
||||
'@tiptap/extension-italic@3.22.1':
|
||||
resolution: {integrity: sha512-EXPZWEsWJK9tUMypddOBvayaBeu8wFV2uH5PNrtDKrfRZ1Bf8GQ3lfcO0blHssaQ9nWqa9HwBC1mdfWcmfpxig==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-link@3.20.5':
|
||||
resolution: {integrity: sha512-0PukrSYnHX2CrGSThlKfQWxpPWmL7QAvdpDUraKknGvVNSH7tUjchTshy5JdLrn/SQAU92REowRCB6zzCNEFjA==}
|
||||
'@tiptap/extension-link@3.22.1':
|
||||
resolution: {integrity: sha512-RHch/Bqv+QDvW3J1CXmiTB54pyrQYNQq8Vfa7is/O209dNPA8tdbkRP44rDjqn8NeDCriC/oJ4avWeXL4qNDVw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/extension-list-item@3.20.5':
|
||||
resolution: {integrity: sha512-pFJCGLIDEin1Xn6B3ctbrZvtYyALARE56ya4SmaNfnl+Hww5MfkRR40obbwYD3byA1yOpr+bECy+I2clQqzTDw==}
|
||||
'@tiptap/extension-list-item@3.22.1':
|
||||
resolution: {integrity: sha512-v0FgSX3cqLY3L1hIe2PFRTR3/+wlFOdFjv0p3fSJ5Tl7cgU7DR1OcljFqpw0exePcmt6dXqXVQua3PxSVV15eA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.5
|
||||
'@tiptap/extension-list': ^3.22.1
|
||||
|
||||
'@tiptap/extension-list-keymap@3.20.5':
|
||||
resolution: {integrity: sha512-rmrQgOrUb0jKtFzVUfT0UNEST2sGM2Ve4lOl+1luh66RW6TD+gvgMk/qo12/Kffl9PUiqz8oYfk2qXCwFb6Bug==}
|
||||
'@tiptap/extension-list-keymap@3.22.1':
|
||||
resolution: {integrity: sha512-00Nz4jJygYGJg6N1mdbQUslFG9QaGZq5P9MFwqoduWku7gYHWkZoZvrkxZrYtxGTHVIlLnF8LIfblAlOwNd76g==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.5
|
||||
'@tiptap/extension-list': ^3.22.1
|
||||
|
||||
'@tiptap/extension-list@3.20.5':
|
||||
resolution: {integrity: sha512-s+Y8Q7Orq+WQiwgFB/VPMYZe+6EAR2F69xCpvOynlzTInLO4cF6QpXomuGEYAZxLHe8ZBmeIaR7y8MH/OgjrDw==}
|
||||
'@tiptap/extension-list@3.22.1':
|
||||
resolution: {integrity: sha512-6bVI5A12sFeyb0EngABV8/qCtC2IgiDbWC8mtNNLh5dAVGaUKo1KucL6vRYDhzXhyO/eHuGYepXZDLOOdS9LIQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/extension-mention@3.20.5':
|
||||
resolution: {integrity: sha512-SEyIV500gAfzylvbWog2gUK6Z6fJhGYXCuGOHAGj+w2Vy3C262w8HXC9uQ+BrY/vdZp8iSpFY4AbTf5xkqkijA==}
|
||||
'@tiptap/extension-mention@3.22.1':
|
||||
resolution: {integrity: sha512-Z6TII6thuMdWZHMaY2dfjggmFOei7tTFR3fOBCmCKue69GnLiueM4EBi0PAl5brIepSerB09A8F9IaMGXauRdw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/suggestion': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
'@tiptap/suggestion': ^3.22.1
|
||||
|
||||
'@tiptap/extension-ordered-list@3.20.5':
|
||||
resolution: {integrity: sha512-Y/RIE3AxUNYAFKGMM5FLlTVKxxBvOh4JlLp/qYsOCY2nJdH0Jopl2FpfBYc4xoJwFSk8BELJ4Ow0adcYb15ksg==}
|
||||
'@tiptap/extension-ordered-list@3.22.1':
|
||||
resolution: {integrity: sha512-sbd99ZUa1lIemH7N6dLB+9aYxUgduwW2216VM3dLJBS9hmTA4iDRxWx0a1ApnAVv+sZasRSbb/wpYLtXviA1XQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.5
|
||||
'@tiptap/extension-list': ^3.22.1
|
||||
|
||||
'@tiptap/extension-paragraph@3.20.5':
|
||||
resolution: {integrity: sha512-mwuhwmff67IpGfOViyRvUC14IlkpsOnB+hSExVnq5+hCntjt/Cr2Z8GGOgzHeIM2FIS0UqX9Lv/b6ttUg4+Now==}
|
||||
'@tiptap/extension-paragraph@3.22.1':
|
||||
resolution: {integrity: sha512-mnvGEZfZFysHGvmEqrSLjeddaNPB3UmomTInv9gsImw8hlB4/gQedvB6Qf2tFfIjl4ISKC5AbFxraSnJfjaL5g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-placeholder@3.20.5':
|
||||
resolution: {integrity: sha512-PcZJbzJ8j+YcRdYWFjmFFVnOOx3nETA0pzMj9fXADi28vNABnrWLwsHAseh3I5QfLmywKQb9SpTSTU2LxQgBoA==}
|
||||
'@tiptap/extension-placeholder@3.22.1':
|
||||
resolution: {integrity: sha512-f8NJNEJTDuT9UIZdVIAPoySgzQ/nKxR/gWRqCnwtR4O26zo/JdKI2XvrTE/iNrV3Khme8rjCtO7/8CQgTeMMxA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.5
|
||||
'@tiptap/extensions': ^3.22.1
|
||||
|
||||
'@tiptap/extension-strike@3.20.5':
|
||||
resolution: {integrity: sha512-uwhvmfS4ciGYJRLUg0AHbWsprMCwyWVWd2RXOLRm0ZQeWkvzonPXZhJvzIhIgsFkPLj/dsN5t0+LdiK4UQMnyA==}
|
||||
'@tiptap/extension-strike@3.22.1':
|
||||
resolution: {integrity: sha512-LTdnGmglK1f/AW//36k+Km8URA1wrTLENi3R5N+/ipv+yP2rZ2Ki1R1m6yJx3KSFzR55c91xE6659/vz1uZ6iA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-text@3.20.5':
|
||||
resolution: {integrity: sha512-DMa9g5cH2d/Gx1KXtV7txTxaa6FBqgG8glmfug+N93VMb8sEZR1Yu1az++yAep4SGGq9GWIGZCUS3H6W66et6Q==}
|
||||
'@tiptap/extension-table-cell@3.22.1':
|
||||
resolution: {integrity: sha512-sDMKaQjtuAxs7j4MTezmCq5rzAFfx3igsHgGPv1rW0ibqDx5rObtOZ6oiPSts8a6cPW5/NGqLaVl0Oa5rxrV/g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/extension-table': ^3.22.1
|
||||
|
||||
'@tiptap/extension-typography@3.20.5':
|
||||
resolution: {integrity: sha512-eZJq5K7cwO1211nZ+MjXs+GeVD2HPFUr11wcZ0zTKlpRSq7yA3zidSOaBJOJ3zJ3iVbis2Ja9XVgv5aEsgMriw==}
|
||||
'@tiptap/extension-table-header@3.22.1':
|
||||
resolution: {integrity: sha512-avkNqG4nxgLoAKFz5+qNZRQJMCmHMDy2Fzg3aB030bJnVzCKoC7RJgWQ8d9T+Sy3LQTR7tngpW1NIozS4TI/wg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/extension-table': ^3.22.1
|
||||
|
||||
'@tiptap/extension-underline@3.20.5':
|
||||
resolution: {integrity: sha512-HMhr5KIAqZsEhlN8RxKHr/ql1a8OvBa9fLf69IwUVFolBcDExHWUtaEV/axYVRQJvvIy2oKGJxlJWDZ4hkotHQ==}
|
||||
'@tiptap/extension-table-row@3.22.1':
|
||||
resolution: {integrity: sha512-EKbwq4h47y+4UrsvOIN8LwFzSpUpYkQQhhk3x6G5xtDsZXc1kRMAowe/S1n3gcXvSkRDF4PxmepzsHsOcaSJIA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/extension-table': ^3.22.1
|
||||
|
||||
'@tiptap/extensions@3.20.5':
|
||||
resolution: {integrity: sha512-c4am6SznqfMnbUNSh4MvufiD7cMLdqL1BArok22uBgSWkS1sB9RVBYe8+x0jrOkk0UPEVlzDHbQ+nU+WmIyS2Q==}
|
||||
'@tiptap/extension-table@3.22.1':
|
||||
resolution: {integrity: sha512-wGioCPgrAhqQ9NNQitVM4sm8WVsu6MBs+4hdgTCtBTA7oEv7EqKWAujY6DA/aPE8uV236pUmosZX3iloHmvpOA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/markdown@3.20.5':
|
||||
resolution: {integrity: sha512-meSibJEeCrh6kPJbdXUNnwexZEgdxWDRu7YzPml8TCy+Djo+g50YwzOfY5bfTYs7/mwGANJ7Y8OnWcnwT2IbzQ==}
|
||||
'@tiptap/extension-text@3.22.1':
|
||||
resolution: {integrity: sha512-wFCNCATSTTFhvA9wOPkAgzPVyG3RM6+jOlDeRhHUCHsFWFWj0w9ZPwA/nP+Qi5hEW7kGG9V8o62RjBdHNvK2PQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/pm@3.20.5':
|
||||
resolution: {integrity: sha512-yJhDa7Chx2EqJMX/jlewBv0za7slf1dKHWYve1XaApuVHEkxl0Ul3EDbwnx316vIITkuFW/pWSwkSsAplyBeCw==}
|
||||
|
||||
'@tiptap/react@3.20.5':
|
||||
resolution: {integrity: sha512-in37o1Eo7JCflcHyK/SDfgkJBgX0LRN3LMk+NdLPTerRnC0zhGLQlpfBL4591TLTOUQde7QIrLv98smYO2mj+w==}
|
||||
'@tiptap/extension-typography@3.22.1':
|
||||
resolution: {integrity: sha512-8gAAsJkVxMeJDO7EKKVtIdMaecws++3Fq86byYucl/MSklj4godSlgOJGer+Fx/l3ToYPTXEQbiL1fnaIWUwkA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extension-underline@3.22.1':
|
||||
resolution: {integrity: sha512-p8/ErqQInWJbpncBycIggmtCjdrMwHmA3GNhOugo6F4fYfeVxgy7pVb7ZF+ss62d0mpQvEd81pyrzhkBtb0nBg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.22.1
|
||||
|
||||
'@tiptap/extensions@3.22.1':
|
||||
resolution: {integrity: sha512-BKpp371Pl1CVcLRLrWH7PC1I+IsXOhet80+pILqCMlwkJnsVtOOVRr5uCF6rbPP4xK5H/ehkQWmxA8rqpv42aA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/markdown@3.22.1':
|
||||
resolution: {integrity: sha512-0w4d6HRKeIsUlemxsxzgdiCURTGJhONrNFyL777zZIgCAbDsTKrUeI+2WNdRJBOIiNdpQiZzUL36vm2JiIDZqw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@tiptap/pm@3.22.1':
|
||||
resolution: {integrity: sha512-OSqSg2974eLJT5PNKFLM7156lBXCUf/dsKTQXWSzsLTf6HOP4dYP6c0YbAk6lgbNI+BdszsHNClmLVLA8H/L9A==}
|
||||
|
||||
'@tiptap/react@3.22.1':
|
||||
resolution: {integrity: sha512-1pIRfgK9wape4nDXVJRfgUcYVZdPPkuECbGtz8bo0rgtdsVN7B8PBVCDyuitZ7acdLbMuuX5+TxeUOvME8np7Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tiptap/starter-kit@3.20.5':
|
||||
resolution: {integrity: sha512-L5E2TCGK0EiwmGIlwMsiwNTW1TLbfPF1Dsji4bSKRJnPbccZIMCB6qdId8v/Z+QGm85NVcBHeruQrDlKDddXBA==}
|
||||
'@tiptap/starter-kit@3.22.1':
|
||||
resolution: {integrity: sha512-1fFmURkgofxgP9GW993bSpxf2rIJzQbWZ9rPw17qbAVuGouIArG+Fd/A1WUD95Vdbx6JIrc1QxbNlLs7bhcoPA==}
|
||||
|
||||
'@tiptap/suggestion@3.20.5':
|
||||
resolution: {integrity: sha512-5fqRNgnzYdJ1oDpyLqwrbVsZwvI+5VW/U89LPMvBYM7sFS7Xd0xfyxyAOWcJN4V0zLeTcuElWN3R+IUTLKbU+Q==}
|
||||
'@tiptap/suggestion@3.22.1':
|
||||
resolution: {integrity: sha512-jNe8WcEQfPj8CkV4uh+gzINDOhjjOz3fEMFmhzDrZrlmwUscYl0NHgvle+LPncCGTy4QSLSK/lG0GP23UAPdqA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.5
|
||||
'@tiptap/pm': ^3.20.5
|
||||
'@tiptap/core': ^3.22.1
|
||||
'@tiptap/pm': ^3.22.1
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
|
||||
@@ -4915,151 +4951,168 @@ snapshots:
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
|
||||
'@tiptap/core@3.20.5(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/core@3.22.1(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/pm': 3.22.1
|
||||
|
||||
'@tiptap/extension-blockquote@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-blockquote@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-bold@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-bold@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/extension-bubble-menu@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-bullet-list@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-bullet-list@3.22.1(@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-list': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-code-block-lowlight@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
'@tiptap/extension-code-block-lowlight@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-code-block': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-code-block': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
highlight.js: 11.11.1
|
||||
lowlight: 3.3.0
|
||||
|
||||
'@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/extension-code-block@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
|
||||
'@tiptap/extension-code@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-code@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-document@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-document@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-dropcursor@3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-dropcursor@3.22.1(@tiptap/extensions@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extensions': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-floating-menu@3.20.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/extension-floating-menu@3.22.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-gapcursor@3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-gapcursor@3.22.1(@tiptap/extensions@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extensions': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-hard-break@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-hard-break@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-heading@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-heading@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/extension-horizontal-rule@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
|
||||
'@tiptap/extension-image@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-image@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-italic@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-italic@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-link@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/extension-link@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
linkifyjs: 4.3.2
|
||||
|
||||
'@tiptap/extension-list-item@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-list-item@3.22.1(@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-list': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-list-keymap@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-list-keymap@3.22.1(@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-list': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
|
||||
'@tiptap/extension-mention@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@tiptap/suggestion@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-mention@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(@tiptap/suggestion@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/suggestion': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
'@tiptap/suggestion': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-ordered-list@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-ordered-list@3.22.1(@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-list': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-paragraph@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-paragraph@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-placeholder@3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-placeholder@3.22.1(@tiptap/extensions@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extensions': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-strike@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-strike@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-text@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-table-cell@3.22.1(@tiptap/extension-table@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-table': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-typography@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-table-header@3.22.1(@tiptap/extension-table@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-table': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-underline@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||
'@tiptap/extension-table-row@3.22.1(@tiptap/extension-table@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-table': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/extension-table@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
|
||||
'@tiptap/markdown@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/extension-text@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-typography@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extension-underline@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
|
||||
'@tiptap/extensions@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
|
||||
'@tiptap/markdown@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
marked: 17.0.5
|
||||
|
||||
'@tiptap/pm@3.20.5':
|
||||
'@tiptap/pm@3.22.1':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.0
|
||||
prosemirror-collab: 1.3.1
|
||||
@@ -5080,10 +5133,10 @@ snapshots:
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
'@tiptap/react@3.20.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@tiptap/react@3.22.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
@@ -5092,42 +5145,42 @@ snapshots:
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@tiptap/extension-bubble-menu': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-floating-menu': 3.20.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-bubble-menu': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-floating-menu': 3.22.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
transitivePeerDependencies:
|
||||
- '@floating-ui/dom'
|
||||
|
||||
'@tiptap/starter-kit@3.20.5':
|
||||
'@tiptap/starter-kit@3.22.1':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-blockquote': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-bold': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-bullet-list': 3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-code': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-code-block': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-document': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-dropcursor': 3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-gapcursor': 3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-hard-break': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-heading': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-horizontal-rule': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-italic': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-link': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/extension-list-item': 3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-list-keymap': 3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-ordered-list': 3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-paragraph': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-strike': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-text': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extension-underline': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-blockquote': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-bold': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-bullet-list': 3.22.1(@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-code': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-code-block': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-document': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-dropcursor': 3.22.1(@tiptap/extensions@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-gapcursor': 3.22.1(@tiptap/extensions@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-hard-break': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-heading': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-horizontal-rule': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-italic': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-link': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-list': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/extension-list-item': 3.22.1(@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-list-keymap': 3.22.1(@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-ordered-list': 3.22.1(@tiptap/extension-list@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-paragraph': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-strike': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-text': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extension-underline': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))
|
||||
'@tiptap/extensions': 3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
|
||||
'@tiptap/suggestion@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
|
||||
'@tiptap/suggestion@3.22.1(@tiptap/core@3.22.1(@tiptap/pm@3.22.1))(@tiptap/pm@3.22.1)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||
'@tiptap/pm': 3.20.5
|
||||
'@tiptap/core': 3.22.1(@tiptap/pm@3.22.1)
|
||||
'@tiptap/pm': 3.22.1
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
dependencies:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
87
server/cmd/multica/cmd_attachment.go
Normal file
87
server/cmd/multica/cmd_attachment.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var attachmentCmd = &cobra.Command{
|
||||
Use: "attachment",
|
||||
Short: "Manage attachments",
|
||||
}
|
||||
|
||||
var attachmentDownloadCmd = &cobra.Command{
|
||||
Use: "download <attachment-id>",
|
||||
Short: "Download an attachment to a local file",
|
||||
Long: "Fetches the attachment metadata from the API, then downloads the file using its signed URL. Prints the local file path on success.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAttachmentDownload,
|
||||
}
|
||||
|
||||
func init() {
|
||||
attachmentCmd.AddCommand(attachmentDownloadCmd)
|
||||
|
||||
attachmentDownloadCmd.Flags().StringP("output-dir", "o", ".", "Directory to save the downloaded file")
|
||||
}
|
||||
|
||||
func runAttachmentDownload(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Fetch attachment metadata (includes signed download_url).
|
||||
var att map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/attachments/"+args[0], &att); err != nil {
|
||||
return fmt.Errorf("get attachment: %w", err)
|
||||
}
|
||||
|
||||
downloadURL := strVal(att, "download_url")
|
||||
if downloadURL == "" {
|
||||
return fmt.Errorf("attachment has no download URL")
|
||||
}
|
||||
|
||||
filename := filepath.Base(strVal(att, "filename"))
|
||||
if filename == "" || filename == "." {
|
||||
filename = args[0]
|
||||
}
|
||||
|
||||
// Download the file content.
|
||||
data, err := client.DownloadFile(ctx, downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download file: %w", err)
|
||||
}
|
||||
|
||||
// Write to the output directory.
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
destPath := filepath.Join(outputDir, filename)
|
||||
|
||||
if err := os.WriteFile(destPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
// Print the absolute path so agents can reference the file.
|
||||
abs, err := filepath.Abs(destPath)
|
||||
if err != nil {
|
||||
abs = destPath
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "Downloaded:", abs)
|
||||
|
||||
// Also print as JSON for --output json compatibility.
|
||||
return cli.PrintJSON(os.Stdout, map[string]any{
|
||||
"id": strVal(att, "id"),
|
||||
"filename": filename,
|
||||
"path": abs,
|
||||
"size": strVal(att, "size_bytes"),
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
306
server/cmd/multica/cmd_runtime.go
Normal file
306
server/cmd/multica/cmd_runtime.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
450
server/cmd/multica/cmd_skill.go
Normal file
450
server/cmd/multica/cmd_skill.go
Normal 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
|
||||
}
|
||||
@@ -32,9 +32,12 @@ func init() {
|
||||
rootCmd.AddCommand(workspaceCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
rootCmd.AddCommand(issueCmd)
|
||||
rootCmd.AddCommand(attachmentCmd)
|
||||
rootCmd.AddCommand(repoCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
rootCmd.AddCommand(skillCmd)
|
||||
rootCmd.AddCommand(runtimeCmd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -230,6 +230,20 @@ func TestCommentTriggerOnComment(t *testing.T) {
|
||||
t.Errorf("expected 1 pending task (assignee mentioned in member thread), got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply to member thread that @mentioned assignee triggers without re-mention", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
// Member starts a thread that @mentions the assignee agent.
|
||||
content := fmt.Sprintf("[@Agent](mention://agent/%s) can you review this?", agentID)
|
||||
threadID := postComment(t, issueID, content, nil)
|
||||
// Clear the task created by the top-level mention.
|
||||
clearTasks(t, issueID)
|
||||
// Reply in the thread WITHOUT re-mentioning the assignee.
|
||||
postComment(t, issueID, "Here is more context for you", strPtr(threadID))
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Errorf("expected 1 pending task (assignee mentioned in thread root), got %d", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCommentTriggerAtAllSuppression verifies that @all mentions do not
|
||||
@@ -323,6 +337,52 @@ func TestCommentTriggerOnMentionNoStatusGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommentTriggerThreadInheritedMention verifies that when a top-level
|
||||
// comment @mentions an agent (not the assignee), replies in that thread
|
||||
// also trigger the mentioned agent — even without explicitly re-mentioning it.
|
||||
func TestCommentTriggerThreadInheritedMention(t *testing.T) {
|
||||
agentID := getAgentID(t)
|
||||
|
||||
// Create an issue NOT assigned to the agent, so on_comment won't fire.
|
||||
issueID := createIssue(t, "Thread-inherited mention test")
|
||||
t.Cleanup(func() {
|
||||
clearTasks(t, issueID)
|
||||
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("reply in thread inherits parent mention", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
// Top-level comment @mentions the agent.
|
||||
content := fmt.Sprintf("[@Agent](mention://agent/%s) can you review this?", agentID)
|
||||
threadID := postComment(t, issueID, content, nil)
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Fatalf("expected 1 pending task after initial mention, got %d", n)
|
||||
}
|
||||
// Clear the task so we can test the reply independently.
|
||||
clearTasks(t, issueID)
|
||||
// Reply in the thread WITHOUT mentioning the agent.
|
||||
postComment(t, issueID, "Here is more context for you", strPtr(threadID))
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Errorf("expected 1 pending task from thread-inherited mention, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply does not double-trigger when re-mentioning same agent", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
// Top-level comment @mentions the agent.
|
||||
content := fmt.Sprintf("[@Agent](mention://agent/%s) help", agentID)
|
||||
threadID := postComment(t, issueID, content, nil)
|
||||
clearTasks(t, issueID)
|
||||
// Reply also @mentions the same agent — should still be just 1 task.
|
||||
reply := fmt.Sprintf("[@Agent](mention://agent/%s) any update?", agentID)
|
||||
postComment(t, issueID, reply, strPtr(threadID))
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Errorf("expected 1 pending task (no duplicate), got %d", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCommentTriggerCoalescing verifies that rapid-fire comments don't create
|
||||
// duplicate tasks (coalescing dedup).
|
||||
func TestCommentTriggerCoalescing(t *testing.T) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
)
|
||||
@@ -29,7 +30,8 @@ var (
|
||||
testWorkspaceID string
|
||||
)
|
||||
|
||||
var jwtSecret = []byte("multica-dev-secret-change-in-production")
|
||||
// jwtSecret is resolved at runtime via auth.JWTSecret() so it respects
|
||||
// the JWT_SECRET env var (set in .env) and stays in sync with the server.
|
||||
|
||||
const (
|
||||
integrationTestEmail = "integration-test@multica.ai"
|
||||
@@ -196,7 +198,7 @@ func generateTestJWT(userID, email, name string) (string, error) {
|
||||
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
return token.SignedString(jwtSecret)
|
||||
return token.SignedString(auth.JWTSecret())
|
||||
}
|
||||
|
||||
// ---- Health ----
|
||||
@@ -417,7 +419,7 @@ func TestInvalidJWT(t *testing.T) {
|
||||
}()},
|
||||
{"expired token", func() string {
|
||||
claims := jwt.MapClaims{"sub": "test", "exp": time.Now().Add(-time.Hour).Unix()}
|
||||
t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
|
||||
t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(auth.JWTSecret())
|
||||
return t
|
||||
}()},
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
})
|
||||
|
||||
// Attachments
|
||||
r.Get("/api/attachments/{id}", h.GetAttachmentByID)
|
||||
r.Delete("/api/attachments/{id}", h.DeleteAttachment)
|
||||
|
||||
// Comments
|
||||
@@ -196,7 +197,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetAgent)
|
||||
r.Put("/", h.UpdateAgent)
|
||||
r.Delete("/", h.DeleteAgent)
|
||||
r.Post("/archive", h.ArchiveAgent)
|
||||
r.Post("/restore", h.RestoreAgent)
|
||||
r.Get("/tasks", h.ListAgentTasks)
|
||||
r.Get("/skills", h.ListAgentSkills)
|
||||
r.Put("/skills", h.SetAgentSkills)
|
||||
|
||||
@@ -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)
|
||||
@@ -212,6 +240,30 @@ func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename st
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file from the given URL and returns the response body.
|
||||
// This is used for downloading attachments via their signed download_url.
|
||||
// Downloads are limited to 100 MB to match the upload size limit.
|
||||
func (c *APIClient) DownloadFile(ctx context.Context, downloadURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("download returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
const maxDownloadSize = 100 << 20 // 100 MB
|
||||
return io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize))
|
||||
}
|
||||
|
||||
// HealthCheck hits the /health endpoint and returns the response body.
|
||||
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
|
||||
|
||||
@@ -11,15 +11,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultServerURL = "ws://localhost:8080/ws"
|
||||
DefaultPollInterval = 3 * time.Second
|
||||
DefaultHeartbeatInterval = 15 * time.Second
|
||||
DefaultAgentTimeout = 2 * time.Hour
|
||||
DefaultRuntimeName = "Local Agent"
|
||||
DefaultServerURL = "ws://localhost:8080/ws"
|
||||
DefaultPollInterval = 3 * time.Second
|
||||
DefaultHeartbeatInterval = 15 * time.Second
|
||||
DefaultAgentTimeout = 2 * time.Hour
|
||||
DefaultRuntimeName = "Local Agent"
|
||||
DefaultConfigReloadInterval = 5 * time.Second
|
||||
DefaultWorkspaceSyncInterval = 30 * time.Second
|
||||
DefaultHealthPort = 19514
|
||||
DefaultMaxConcurrentTasks = 20
|
||||
DefaultMaxConcurrentTasks = 20
|
||||
)
|
||||
|
||||
// Config holds all daemon configuration.
|
||||
@@ -30,7 +30,7 @@ type Config struct {
|
||||
RuntimeName string
|
||||
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
||||
Profile string // profile name (empty = default)
|
||||
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry
|
||||
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry, "opencode" -> entry
|
||||
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
||||
KeepEnvAfterTask bool // preserve env after task for debugging
|
||||
HealthPort int // local HTTP port for health checks (default: 19514)
|
||||
@@ -85,8 +85,15 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")),
|
||||
}
|
||||
}
|
||||
opencodePath := envOrDefault("MULTICA_OPENCODE_PATH", "opencode")
|
||||
if _, err := exec.LookPath(opencodePath); err == nil {
|
||||
agents["opencode"] = AgentEntry{
|
||||
Path: opencodePath,
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCODE_MODEL")),
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude or codex and ensure it is on PATH")
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, or opencode and ensure it is on PATH")
|
||||
}
|
||||
|
||||
// Host info
|
||||
|
||||
@@ -11,9 +11,10 @@ import (
|
||||
// writeContextFiles renders and writes .agent_context/issue_context.md and
|
||||
// skills into the appropriate provider-native location.
|
||||
//
|
||||
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
||||
// Codex: skills → handled separately in Prepare via codex-home
|
||||
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
|
||||
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
||||
// Codex: skills → handled separately in Prepare via codex-home
|
||||
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
|
||||
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
|
||||
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
contextDir := filepath.Join(workDir, ".agent_context")
|
||||
if err := os.MkdirAll(contextDir, 0o755); err != nil {
|
||||
@@ -50,6 +51,9 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
|
||||
case "claude":
|
||||
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".claude", "skills")
|
||||
case "opencode":
|
||||
// OpenCode natively discovers skills from .config/opencode/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".config", "opencode", "skills")
|
||||
default:
|
||||
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
||||
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
|
||||
|
||||
@@ -441,6 +441,148 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueID: "opencode-skill-test",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{
|
||||
Name: "Go Conventions",
|
||||
Content: "Follow Go conventions.",
|
||||
Files: []SkillFileContextForEnv{
|
||||
{Path: "templates/example.go", Content: "package main"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "opencode", ctx); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
// Skills should be in .config/opencode/skills/ (native discovery).
|
||||
skillMd, err := os.ReadFile(filepath.Join(dir, ".config", "opencode", "skills", "go-conventions", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read .config/opencode/skills/go-conventions/SKILL.md: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
|
||||
t.Error("SKILL.md missing content")
|
||||
}
|
||||
|
||||
// Supporting files should also be under .config/opencode/skills/.
|
||||
supportFile, err := os.ReadFile(filepath.Join(dir, ".config", "opencode", "skills", "go-conventions", "templates", "example.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read supporting file: %v", err)
|
||||
}
|
||||
if string(supportFile) != "package main" {
|
||||
t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main")
|
||||
}
|
||||
|
||||
// .agent_context/skills/ should NOT exist for OpenCode.
|
||||
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
|
||||
t.Error("expected .agent_context/skills/ to NOT exist for OpenCode provider")
|
||||
}
|
||||
|
||||
// issue_context.md should still be in .agent_context/.
|
||||
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "issue_context.md")); os.IsNotExist(err) {
|
||||
t.Error("expected .agent_context/issue_context.md to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigOpencode(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueID: "test-issue-id",
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "opencode", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// OpenCode uses AGENTS.md (same as codex).
|
||||
content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read AGENTS.md: %v", err)
|
||||
}
|
||||
|
||||
s := string(content)
|
||||
if !strings.Contains(s, "Multica Agent Runtime") {
|
||||
t.Error("AGENTS.md missing meta skill header")
|
||||
}
|
||||
if !strings.Contains(s, "Coding") {
|
||||
t.Error("AGENTS.md missing skill name")
|
||||
}
|
||||
if !strings.Contains(s, "discovered automatically") {
|
||||
t.Error("AGENTS.md missing native skill discovery hint")
|
||||
}
|
||||
|
||||
// CLAUDE.md should NOT exist.
|
||||
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
|
||||
t.Error("expected CLAUDE.md to NOT exist for OpenCode provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareWithRepoContextOpencode(t *testing.T) {
|
||||
t.Parallel()
|
||||
workspacesRoot := t.TempDir()
|
||||
|
||||
taskCtx := TaskContextForEnv{
|
||||
IssueID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
|
||||
Repos: []RepoContextForEnv{
|
||||
{URL: "https://github.com/org/backend", Description: "Go backend"},
|
||||
},
|
||||
}
|
||||
env, err := Prepare(PrepareParams{
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
WorkspaceID: "ws-test-oc",
|
||||
TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
|
||||
AgentName: "OpenCode Agent",
|
||||
Provider: "opencode",
|
||||
Task: taskCtx,
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare failed: %v", err)
|
||||
}
|
||||
defer env.Cleanup(true)
|
||||
|
||||
if err := InjectRuntimeConfig(env.WorkDir, "opencode", taskCtx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Workdir should only contain expected entries.
|
||||
entries, err := os.ReadDir(env.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read workdir: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if name != ".agent_context" && name != "AGENTS.md" {
|
||||
t.Errorf("unexpected entry in workdir: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// AGENTS.md should contain repo info.
|
||||
content, err := os.ReadFile(filepath.Join(env.WorkDir, "AGENTS.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read AGENTS.md: %v", err)
|
||||
}
|
||||
s := string(content)
|
||||
for _, want := range []string{
|
||||
"multica repo checkout",
|
||||
"https://github.com/org/backend",
|
||||
"Go backend",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("AGENTS.md missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -10,15 +10,16 @@ import (
|
||||
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
|
||||
// config file so the agent discovers its environment through its native mechanism.
|
||||
//
|
||||
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
|
||||
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
|
||||
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
|
||||
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
|
||||
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/)
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
content := buildMetaSkillContent(provider, ctx)
|
||||
|
||||
switch provider {
|
||||
case "claude":
|
||||
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
case "codex":
|
||||
case "codex", "opencode":
|
||||
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
default:
|
||||
// Unknown provider — skip config injection, prompt-only mode.
|
||||
@@ -46,11 +47,12 @@ 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")
|
||||
b.WriteString("- `multica issue run-messages <task-id> [--since <seq>] --output json` — List messages for a specific execution run (supports incremental fetch)\n\n")
|
||||
b.WriteString("- `multica issue run-messages <task-id> [--since <seq>] --output json` — List messages for a specific execution run (supports incremental fetch)\n")
|
||||
b.WriteString("- `multica attachment download <id> [-o <dir>]` — Download an attachment file locally by ID\n\n")
|
||||
|
||||
b.WriteString("### Write\n")
|
||||
b.WriteString("- `multica issue comment add <issue-id> --content \"...\" [--parent <comment-id>]` — Post a comment (use --parent to reply to a specific comment)\n")
|
||||
@@ -81,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")
|
||||
@@ -114,8 +117,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
case "claude":
|
||||
// Claude discovers skills natively from .claude/skills/ — just list names.
|
||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||
case "codex":
|
||||
// Codex discovers skills natively via CODEX_HOME/skills/ — just list names.
|
||||
case "codex", "opencode":
|
||||
// Codex and OpenCode discover skills natively from their respective paths — just list names.
|
||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||
default:
|
||||
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
|
||||
@@ -133,6 +136,13 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("- **Agent**: `[@Name](mention://agent/<agent-id>)` — renders as a styled mention\n\n")
|
||||
b.WriteString("Use `multica issue list --output json` to look up issue IDs, and `multica workspace members --output json` for member IDs.\n\n")
|
||||
|
||||
b.WriteString("## Attachments\n\n")
|
||||
b.WriteString("Issues and comments may include file attachments (images, documents, etc.).\n")
|
||||
b.WriteString("Use the download command to fetch attachment files locally:\n\n")
|
||||
b.WriteString("```\nmultica attachment download <attachment-id>\n```\n\n")
|
||||
b.WriteString("This downloads the file to the current directory and prints the local path. Use `-o <dir>` to save elsewhere.\n")
|
||||
b.WriteString("After downloading, you can read the file directly (e.g. view an image, read a document).\n\n")
|
||||
|
||||
b.WriteString("## Output\n\n")
|
||||
b.WriteString("Keep comments concise and natural — state the outcome, not the process.\n")
|
||||
b.WriteString("Good: \"Fixed the login redirect. PR: https://...\"\n")
|
||||
|
||||
@@ -29,7 +29,7 @@ type CachedRepo struct {
|
||||
|
||||
// Cache manages bare git clones for workspace repositories.
|
||||
type Cache struct {
|
||||
root string // base directory for all caches (e.g. ~/multica_workspaces/.repos)
|
||||
root string // base directory for all caches (e.g. ~/multica_workspaces/.repos)
|
||||
logger *slog.Logger
|
||||
mu sync.Mutex
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
}
|
||||
|
||||
// Exclude agent context files from git tracking.
|
||||
for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude"} {
|
||||
for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude", ".config/opencode"} {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ type AgentResponse struct {
|
||||
Triggers any `json:"triggers"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
ArchivedBy *string `json:"archived_by"`
|
||||
}
|
||||
|
||||
func agentToResponse(a db.Agent) AgentResponse {
|
||||
@@ -78,6 +80,8 @@ func agentToResponse(a db.Agent) AgentResponse {
|
||||
Triggers: triggers,
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
UpdatedAt: timestampToString(a.UpdatedAt),
|
||||
ArchivedAt: timestampToPtr(a.ArchivedAt),
|
||||
ArchivedBy: uuidToPtr(a.ArchivedBy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +150,13 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
agents, err := h.Queries.ListAgents(r.Context(), parseUUID(workspaceID))
|
||||
var agents []db.Agent
|
||||
var err error
|
||||
if r.URL.Query().Get("include_archived") == "true" {
|
||||
agents, err = h.Queries.ListAllAgents(r.Context(), parseUUID(workspaceID))
|
||||
} else {
|
||||
agents, err = h.Queries.ListAgents(r.Context(), parseUUID(workspaceID))
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agents")
|
||||
return
|
||||
@@ -317,7 +327,7 @@ type UpdateAgentRequest struct {
|
||||
Triggers any `json:"triggers"`
|
||||
}
|
||||
|
||||
// canManageAgent checks whether the current user can update or delete an agent.
|
||||
// canManageAgent checks whether the current user can update or archive an agent.
|
||||
// Only the agent owner or workspace owner/admin can manage any agent,
|
||||
// regardless of whether it is public or private.
|
||||
func (h *Handler) canManageAgent(w http.ResponseWriter, r *http.Request, agent db.Agent) bool {
|
||||
@@ -415,30 +425,72 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) ArchiveAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, ok := h.loadAgentForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsID := uuidToString(agent.WorkspaceID)
|
||||
|
||||
if !h.canManageAgent(w, r, agent) {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.Queries.DeleteAgent(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
slog.Warn("delete agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete agent")
|
||||
if agent.ArchivedAt.Valid {
|
||||
writeError(w, http.StatusConflict, "agent is already archived")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("agent deleted", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...)
|
||||
userID := requestUserID(r)
|
||||
archived, err := h.Queries.ArchiveAgent(r.Context(), db.ArchiveAgentParams{
|
||||
ID: parseUUID(id),
|
||||
ArchivedBy: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("archive agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to archive agent")
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel all pending/active tasks for this agent.
|
||||
if err := h.Queries.CancelAgentTasksByAgent(r.Context(), parseUUID(id)); err != nil {
|
||||
slog.Warn("cancel agent tasks on archive failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
}
|
||||
|
||||
wsID := uuidToString(archived.WorkspaceID)
|
||||
slog.Info("agent archived", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...)
|
||||
resp := agentToResponse(archived)
|
||||
actorType, actorID := h.resolveActor(r, userID, wsID)
|
||||
h.publish(protocol.EventAgentArchived, wsID, actorType, actorID, map[string]any{"agent": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) RestoreAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, ok := h.loadAgentForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.canManageAgent(w, r, agent) {
|
||||
return
|
||||
}
|
||||
if !agent.ArchivedAt.Valid {
|
||||
writeError(w, http.StatusConflict, "agent is not archived")
|
||||
return
|
||||
}
|
||||
|
||||
restored, err := h.Queries.RestoreAgent(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
slog.Warn("restore agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to restore agent")
|
||||
return
|
||||
}
|
||||
|
||||
wsID := uuidToString(restored.WorkspaceID)
|
||||
slog.Info("agent restored", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...)
|
||||
resp := agentToResponse(restored)
|
||||
userID := requestUserID(r)
|
||||
actorType, actorID := h.resolveActor(r, userID, wsID)
|
||||
h.publish(protocol.EventAgentDeleted, wsID, actorType, actorID, map[string]any{"agent_id": id, "workspace_id": wsID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
h.publish(protocol.EventAgentRestored, wsID, actorType, actorID, map[string]any{"agent": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -188,7 +272,8 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Trigger @mentioned agents: parse agent mentions and enqueue tasks for each.
|
||||
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, authorType, authorID)
|
||||
// Pass parentComment so that replies inherit mentions from the thread root.
|
||||
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, parentComment, authorType, authorID)
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
@@ -201,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) {
|
||||
@@ -226,6 +319,8 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
|
||||
// continuing a human conversation — not requesting work from the assigned agent.
|
||||
// Replying to an agent-started thread, or explicitly @mentioning the assignee
|
||||
// in the reply, still triggers on_comment as expected.
|
||||
// If the parent (thread root) itself @mentions the assignee, the thread is
|
||||
// considered a conversation with the agent, so replies are allowed to trigger.
|
||||
func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issue db.Issue) bool {
|
||||
if parent == nil {
|
||||
return false // Not a reply — normal top-level comment
|
||||
@@ -234,30 +329,56 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
|
||||
return false // Thread started by an agent — allow trigger
|
||||
}
|
||||
// Thread was started by a member. Suppress on_comment unless the reply
|
||||
// explicitly @mentions the assignee agent.
|
||||
// or the parent explicitly @mentions the assignee agent.
|
||||
if !issue.AssigneeID.Valid {
|
||||
return true // No assignee to mention
|
||||
}
|
||||
assigneeID := uuidToString(issue.AssigneeID)
|
||||
// Check current comment mentions.
|
||||
for _, m := range util.ParseMentions(content) {
|
||||
if m.ID == assigneeID {
|
||||
return false // Assignee explicitly mentioned — allow trigger
|
||||
return false // Assignee explicitly mentioned in reply — allow trigger
|
||||
}
|
||||
}
|
||||
// Check parent (thread root) mentions — if the thread was started by
|
||||
// mentioning the assignee, replies continue that conversation.
|
||||
for _, m := range util.ParseMentions(parent.Content) {
|
||||
if m.ID == assigneeID {
|
||||
return false // Assignee mentioned in thread root — allow trigger
|
||||
}
|
||||
}
|
||||
return true // Reply to member thread without mentioning agent — suppress
|
||||
}
|
||||
|
||||
// enqueueMentionedAgentTasks parses @agent mentions from comment content and
|
||||
// enqueues a task for each mentioned agent. Skips self-mentions, agents that
|
||||
// are already the issue's assignee (handled by on_comment), agents with
|
||||
// on_mention trigger disabled, and private agents mentioned by non-owner
|
||||
// members (only the agent owner or workspace admin/owner can mention a
|
||||
// private agent).
|
||||
// enqueues a task for each mentioned agent. When parentComment is non-nil
|
||||
// (i.e. the comment is a reply), mentions from the parent (thread root) are
|
||||
// also included so that agents mentioned in the top-level comment are
|
||||
// re-triggered by subsequent replies in the same thread.
|
||||
// Skips self-mentions, agents that are already the issue's assignee (handled
|
||||
// by on_comment), agents with on_mention trigger disabled, and private agents
|
||||
// mentioned by non-owner members (only the agent owner or workspace
|
||||
// admin/owner can mention a private agent).
|
||||
// Note: no status gate here — @mention is an explicit action and should work
|
||||
// even on done/cancelled issues (the agent can reopen the issue if needed).
|
||||
func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, authorType, authorID string) {
|
||||
func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, parentComment *db.Comment, authorType, authorID string) {
|
||||
wsID := uuidToString(issue.WorkspaceID)
|
||||
mentions := util.ParseMentions(comment.Content)
|
||||
// When replying in a thread, also include mentions from the parent comment
|
||||
// so that agents mentioned in the thread root are triggered by replies.
|
||||
if parentComment != nil {
|
||||
parentMentions := util.ParseMentions(parentComment.Content)
|
||||
seen := make(map[string]bool, len(mentions))
|
||||
for _, m := range mentions {
|
||||
seen[m.Type+":"+m.ID] = true
|
||||
}
|
||||
for _, m := range parentMentions {
|
||||
if !seen[m.Type+":"+m.ID] {
|
||||
mentions = append(mentions, m)
|
||||
seen[m.Type+":"+m.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range mentions {
|
||||
if m.Type != "agent" {
|
||||
continue
|
||||
@@ -273,9 +394,9 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
|
||||
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID {
|
||||
continue
|
||||
}
|
||||
// Load the agent to check visibility and trigger config.
|
||||
// Load the agent to check visibility, archive status, and trigger config.
|
||||
agent, err := h.Queries.GetAgent(ctx, agentUUID)
|
||||
if err != nil || !agent.RuntimeID.Valid {
|
||||
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||||
continue
|
||||
}
|
||||
// Private agents can only be mentioned by the agent owner or workspace admin/owner.
|
||||
|
||||
@@ -51,7 +51,7 @@ func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse {
|
||||
CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
if h.CFSigner != nil {
|
||||
resp.DownloadURL = h.CFSigner.SignedURL(a.Url, time.Now().Add(5*time.Minute))
|
||||
resp.DownloadURL = h.CFSigner.SignedURL(a.Url, time.Now().Add(30*time.Minute))
|
||||
}
|
||||
if a.IssueID.Valid {
|
||||
s := uuidToString(a.IssueID)
|
||||
@@ -217,6 +217,30 @@ func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetAttachmentByID — GET /api/attachments/{id}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *Handler) GetAttachmentByID(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "id")
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{
|
||||
ID: parseUUID(attachmentID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, h.attachmentToResponse(att))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteAttachment — DELETE /api/attachments/{id}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -466,6 +466,9 @@ func (h *Handler) canAssignAgent(ctx context.Context, r *http.Request, agentID,
|
||||
if err != nil {
|
||||
return false, "agent not found"
|
||||
}
|
||||
if agent.ArchivedAt.Valid {
|
||||
return false, "cannot assign to archived agent"
|
||||
}
|
||||
if agent.Visibility != "private" {
|
||||
return true, ""
|
||||
}
|
||||
@@ -521,7 +524,7 @@ func (h *Handler) isAgentTriggerEnabled(ctx context.Context, issue db.Issue, tri
|
||||
}
|
||||
|
||||
agent, err := h.Queries.GetAgent(ctx, issue.AssigneeID)
|
||||
if err != nil || !agent.RuntimeID.Valid {
|
||||
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -117,8 +127,20 @@ func TestIsReplyToMemberThread(t *testing.T) {
|
||||
h := &Handler{}
|
||||
issue := issueWithAgentAssignee()
|
||||
|
||||
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)}
|
||||
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)}
|
||||
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "plain thread starter"}
|
||||
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"}
|
||||
// Member-started thread root that @mentions the assignee agent.
|
||||
memberParentMentioningAssignee := &db.Comment{
|
||||
AuthorType: "member",
|
||||
AuthorID: testUUID(memberID),
|
||||
Content: fmt.Sprintf("[@Agent](mention://agent/%s) can you look at this?", agentAssigneeID),
|
||||
}
|
||||
// Member-started thread root that @mentions a non-assignee agent.
|
||||
memberParentMentioningOther := &db.Comment{
|
||||
AuthorType: "member",
|
||||
AuthorID: testUUID(memberID),
|
||||
Content: fmt.Sprintf("[@Other](mention://agent/%s) what do you think?", otherAgentID),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -168,6 +190,18 @@ func TestIsReplyToMemberThread(t *testing.T) {
|
||||
content: fmt.Sprintf("[@Other](mention://agent/%s) take a look", otherAgentID),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "reply to member thread that @mentioned assignee, no re-mention → allow",
|
||||
parent: memberParentMentioningAssignee,
|
||||
content: "here is more context for you",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "reply to member thread that @mentioned other agent, no re-mention → suppress",
|
||||
parent: memberParentMentioningOther,
|
||||
content: "here is more context",
|
||||
want: true, // parent mentioned other agent, not assignee — still suppress on_comment
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -188,8 +222,13 @@ func TestOnCommentTriggerDecision(t *testing.T) {
|
||||
h := &Handler{}
|
||||
issue := issueWithAgentAssignee()
|
||||
|
||||
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)}
|
||||
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)}
|
||||
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "plain thread starter"}
|
||||
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"}
|
||||
memberParentMentioningAssignee := &db.Comment{
|
||||
AuthorType: "member",
|
||||
AuthorID: testUUID(memberID),
|
||||
Content: fmt.Sprintf("[@Agent](mention://agent/%s) help me", agentAssigneeID),
|
||||
}
|
||||
|
||||
// Simulates the combined check from CreateComment:
|
||||
// !commentMentionsOthersButNotAssignee && !isReplyToMemberThread
|
||||
@@ -213,6 +252,7 @@ func TestOnCommentTriggerDecision(t *testing.T) {
|
||||
{"reply member thread, no mention", memberParent, "agreed", false},
|
||||
{"reply member thread, mention other member", memberParent, fmt.Sprintf("[@Bob](mention://member/%s) ok", memberID), false},
|
||||
{"reply member thread, mention assignee", memberParent, fmt.Sprintf("[@Agent](mention://agent/%s) help", agentAssigneeID), true},
|
||||
{"reply member thread that @mentioned assignee, no re-mention", memberParentMentioningAssignee, "here is more info", true},
|
||||
{"top-level, @all broadcast", nil, "[@All](mention://all/all) heads up team", false},
|
||||
{"reply agent thread, @all broadcast", agentParent, "[@All](mention://all/all) update for everyone", false},
|
||||
{"reply member thread, @all broadcast", memberParent, "[@All](mention://all/all) fyi", false},
|
||||
|
||||
@@ -44,6 +44,10 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err)
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
||||
}
|
||||
if agent.ArchivedAt.Valid {
|
||||
slog.Debug("task enqueue skipped: agent is archived", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agent.ID))
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("agent is archived")
|
||||
}
|
||||
if !agent.RuntimeID.Valid {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", "agent has no runtime")
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
||||
@@ -79,6 +83,10 @@ func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue,
|
||||
slog.Error("mention task enqueue failed: agent not found", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err)
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
||||
}
|
||||
if agent.ArchivedAt.Valid {
|
||||
slog.Debug("mention task enqueue skipped: agent is archived", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID))
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("agent is archived")
|
||||
}
|
||||
if !agent.RuntimeID.Valid {
|
||||
slog.Error("mention task enqueue failed: agent has no runtime", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID))
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
||||
@@ -549,5 +557,7 @@ func agentToMap(a db.Agent) map[string]any {
|
||||
"triggers": triggers,
|
||||
"created_at": util.TimestampToString(a.CreatedAt),
|
||||
"updated_at": util.TimestampToString(a.UpdatedAt),
|
||||
"archived_at": util.TimestampToPtr(a.ArchivedAt),
|
||||
"archived_by": util.UUIDToPtr(a.ArchivedBy),
|
||||
}
|
||||
}
|
||||
|
||||
2
server/migrations/031_agent_archive.down.sql
Normal file
2
server/migrations/031_agent_archive.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE agent DROP COLUMN IF EXISTS archived_by;
|
||||
ALTER TABLE agent DROP COLUMN IF EXISTS archived_at;
|
||||
4
server/migrations/031_agent_archive.up.sql
Normal file
4
server/migrations/031_agent_archive.up.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Add archive support to agents (soft-delete replacement).
|
||||
-- archived_at IS NOT NULL means the agent is archived.
|
||||
ALTER TABLE agent ADD COLUMN archived_at TIMESTAMPTZ DEFAULT NULL;
|
||||
ALTER TABLE agent ADD COLUMN archived_by UUID DEFAULT NULL REFERENCES "user"(id);
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package agent provides a unified interface for executing prompts via
|
||||
// coding agents (Claude Code, Codex). It mirrors the happy-cli AgentBackend
|
||||
// coding agents (Claude Code, Codex, OpenCode). It mirrors the happy-cli AgentBackend
|
||||
// pattern, translated to idiomatic Go.
|
||||
package agent
|
||||
|
||||
@@ -25,7 +25,7 @@ type ExecOptions struct {
|
||||
SystemPrompt string
|
||||
MaxTurns int
|
||||
Timeout time.Duration
|
||||
ResumeSessionID string // if non-empty, resume a previous Claude Code session
|
||||
ResumeSessionID string // if non-empty, resume a previous agent session
|
||||
}
|
||||
|
||||
// Session represents a running agent execution.
|
||||
@@ -73,13 +73,13 @@ type Result struct {
|
||||
|
||||
// Config configures a Backend instance.
|
||||
type Config struct {
|
||||
ExecutablePath string // path to CLI binary (claude or codex)
|
||||
ExecutablePath string // path to CLI binary (claude, codex, or opencode)
|
||||
Env map[string]string // extra environment variables
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Backend for the given agent type.
|
||||
// Supported types: "claude", "codex".
|
||||
// Supported types: "claude", "codex", "opencode".
|
||||
func New(agentType string, cfg Config) (Backend, error) {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
@@ -90,8 +90,10 @@ func New(agentType string, cfg Config) (Backend, error) {
|
||||
return &claudeBackend{cfg: cfg}, nil
|
||||
case "codex":
|
||||
return &codexBackend{cfg: cfg}, nil
|
||||
case "opencode":
|
||||
return &opencodeBackend{cfg: cfg}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex)", agentType)
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode)", agentType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
312
server/pkg/agent/opencode.go
Normal file
312
server/pkg/agent/opencode.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// opencodeBackend implements Backend by spawning `opencode run --format json`
|
||||
// and reading streaming JSON events from stdout — the same pattern as Claude.
|
||||
type opencodeBackend struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
||||
execPath := b.cfg.ExecutablePath
|
||||
if execPath == "" {
|
||||
execPath = "opencode"
|
||||
}
|
||||
if _, err := exec.LookPath(execPath); err != nil {
|
||||
return nil, fmt.Errorf("opencode executable not found at %q: %w", execPath, err)
|
||||
}
|
||||
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Minute
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
args := []string{"run", "--format", "json"}
|
||||
if opts.Model != "" {
|
||||
args = append(args, "--model", opts.Model)
|
||||
}
|
||||
if opts.SystemPrompt != "" {
|
||||
args = append(args, "--prompt", opts.SystemPrompt)
|
||||
}
|
||||
if opts.MaxTurns > 0 {
|
||||
b.cfg.Logger.Warn("opencode does not support --max-turns; ignoring", "maxTurns", opts.MaxTurns)
|
||||
}
|
||||
if opts.ResumeSessionID != "" {
|
||||
args = append(args, "--session", opts.ResumeSessionID)
|
||||
}
|
||||
args = append(args, prompt)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
|
||||
env := buildEnv(b.cfg.Env)
|
||||
// Auto-approve all tool use in daemon mode.
|
||||
env = append(env, `OPENCODE_PERMISSION={"*":"allow"}`)
|
||||
cmd.Env = env
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("opencode stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = newLogWriter(b.cfg.Logger, "[opencode:stderr] ")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start opencode: %w", err)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Info("opencode started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
defer close(resCh)
|
||||
|
||||
startTime := time.Now()
|
||||
scanResult := b.processEvents(stdout, msgCh)
|
||||
|
||||
// Wait for process exit.
|
||||
exitErr := cmd.Wait()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
scanResult.status = "timeout"
|
||||
scanResult.errMsg = fmt.Sprintf("opencode timed out after %s", timeout)
|
||||
} else if runCtx.Err() == context.Canceled {
|
||||
scanResult.status = "aborted"
|
||||
scanResult.errMsg = "execution cancelled"
|
||||
} else if exitErr != nil && scanResult.status == "completed" {
|
||||
scanResult.status = "failed"
|
||||
scanResult.errMsg = fmt.Sprintf("opencode exited with error: %v", exitErr)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Info("opencode finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String())
|
||||
|
||||
resCh <- Result{
|
||||
Status: scanResult.status,
|
||||
Output: scanResult.output,
|
||||
Error: scanResult.errMsg,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
SessionID: scanResult.sessionID,
|
||||
}
|
||||
}()
|
||||
|
||||
return &Session{Messages: msgCh, Result: resCh}, nil
|
||||
}
|
||||
|
||||
// ── Event handlers ──
|
||||
|
||||
// eventResult holds the accumulated state from processing the event stream.
|
||||
type eventResult struct {
|
||||
status string
|
||||
errMsg string
|
||||
output string
|
||||
sessionID string
|
||||
}
|
||||
|
||||
// processEvents reads JSON lines from r, dispatches events to ch, and returns
|
||||
// the accumulated result. This is the core scanner loop, extracted for testability.
|
||||
func (b *opencodeBackend) processEvents(r io.Reader, ch chan<- Message) eventResult {
|
||||
var output strings.Builder
|
||||
var sessionID string
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event opencodeEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if event.SessionID != "" {
|
||||
sessionID = event.SessionID
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "text":
|
||||
b.handleTextEvent(event, ch, &output)
|
||||
case "tool_use":
|
||||
b.handleToolUseEvent(event, ch)
|
||||
case "error":
|
||||
b.handleErrorEvent(event, ch, &finalStatus, &finalError)
|
||||
case "step_start":
|
||||
trySend(ch, Message{Type: MessageStatus, Status: "running"})
|
||||
case "step_finish":
|
||||
// Captures final session ID from step_finish if present.
|
||||
}
|
||||
}
|
||||
|
||||
// Check for scanner errors (e.g. broken pipe, read errors).
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
b.cfg.Logger.Warn("opencode stdout scanner error", "error", scanErr)
|
||||
if finalStatus == "completed" {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("stdout read error: %v", scanErr)
|
||||
}
|
||||
}
|
||||
|
||||
return eventResult{
|
||||
status: finalStatus,
|
||||
errMsg: finalError,
|
||||
output: output.String(),
|
||||
sessionID: sessionID,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *opencodeBackend) handleTextEvent(event opencodeEvent, ch chan<- Message, output *strings.Builder) {
|
||||
text := event.Part.Text
|
||||
if text != "" {
|
||||
output.WriteString(text)
|
||||
trySend(ch, Message{Type: MessageText, Content: text})
|
||||
}
|
||||
}
|
||||
|
||||
// handleToolUseEvent processes "tool_use" events from opencode. A single
|
||||
// tool_use event contains both the call and result in part.state when the
|
||||
// tool has completed (state.status == "completed").
|
||||
func (b *opencodeBackend) handleToolUseEvent(event opencodeEvent, ch chan<- Message) {
|
||||
// Extract input from state.input (the tool invocation parameters).
|
||||
var input map[string]any
|
||||
if event.Part.State != nil && event.Part.State.Input != nil {
|
||||
_ = json.Unmarshal(event.Part.State.Input, &input)
|
||||
}
|
||||
|
||||
// Emit the tool-use message.
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: event.Part.Tool,
|
||||
CallID: event.Part.CallID,
|
||||
Input: input,
|
||||
})
|
||||
|
||||
// If the tool has completed, also emit a tool-result message.
|
||||
if event.Part.State != nil && event.Part.State.Status == "completed" {
|
||||
outputStr := extractToolOutput(event.Part.State.Output)
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolResult,
|
||||
Tool: event.Part.Tool,
|
||||
CallID: event.Part.CallID,
|
||||
Output: outputStr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleErrorEvent processes "error" events from opencode. OpenCode can exit
|
||||
// with RC=0 even on errors (e.g. invalid model), so error events are the
|
||||
// reliable signal for failures.
|
||||
func (b *opencodeBackend) handleErrorEvent(event opencodeEvent, ch chan<- Message, finalStatus, finalError *string) {
|
||||
errMsg := ""
|
||||
if event.Error != nil {
|
||||
errMsg = event.Error.Message()
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown opencode error"
|
||||
}
|
||||
|
||||
b.cfg.Logger.Warn("opencode error event", "error", errMsg)
|
||||
trySend(ch, Message{Type: MessageError, Content: errMsg})
|
||||
|
||||
*finalStatus = "failed"
|
||||
*finalError = errMsg
|
||||
}
|
||||
|
||||
// extractToolOutput converts the tool state output (which may be a string or
|
||||
// structured object) into a string.
|
||||
func extractToolOutput(output any) string {
|
||||
if output == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := output.(string); ok {
|
||||
return s
|
||||
}
|
||||
data, _ := json.Marshal(output)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ── JSON types for `opencode run --format json` stdout events ──
|
||||
|
||||
// opencodeEvent represents a single JSON line from `opencode run --format json`.
|
||||
//
|
||||
// Event types observed in real output:
|
||||
//
|
||||
// "step_start" — agent step begins
|
||||
// "text" — text output from agent (part.text)
|
||||
// "tool_use" — tool invocation with call and result (part.tool, part.callID, part.state)
|
||||
// "error" — error from opencode (error.name, error.data.message)
|
||||
// "step_finish" — agent step completes (includes token usage)
|
||||
type opencodeEvent struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
SessionID string `json:"sessionID,omitempty"`
|
||||
Part opencodeEventPart `json:"part"`
|
||||
Error *opencodeError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// opencodeEventPart represents the part field in an opencode event.
|
||||
type opencodeEventPart struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
MessageID string `json:"messageID,omitempty"`
|
||||
SessionID string `json:"sessionID,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
|
||||
// Text events
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// Tool use events
|
||||
Tool string `json:"tool,omitempty"`
|
||||
CallID string `json:"callID,omitempty"`
|
||||
State *opencodeToolState `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
// opencodeToolState represents the state of a tool invocation.
|
||||
type opencodeToolState struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Output any `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// opencodeError represents an error event from opencode.
|
||||
type opencodeError struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Data *opencodeErrData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Message returns the human-readable error message.
|
||||
func (e *opencodeError) Message() string {
|
||||
if e.Data != nil && e.Data.Message != "" {
|
||||
return e.Data.Message
|
||||
}
|
||||
if e.Name != "" {
|
||||
return e.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type opencodeErrData struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
711
server/pkg/agent/opencode_test.go
Normal file
711
server/pkg/agent/opencode_test.go
Normal file
@@ -0,0 +1,711 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewReturnsOpencodeBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := New("opencode", Config{ExecutablePath: "/nonexistent/opencode"})
|
||||
if err != nil {
|
||||
t.Fatalf("New(opencode) error: %v", err)
|
||||
}
|
||||
if _, ok := b.(*opencodeBackend); !ok {
|
||||
t.Fatalf("expected *opencodeBackend, got %T", b)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text event tests ──
|
||||
|
||||
func TestOpencodeHandleTextEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
event := opencodeEvent{
|
||||
Type: "text",
|
||||
SessionID: "ses_abc",
|
||||
Part: opencodeEventPart{
|
||||
Type: "text",
|
||||
Text: "Hello from opencode",
|
||||
},
|
||||
}
|
||||
|
||||
b.handleTextEvent(event, ch, &output)
|
||||
|
||||
if output.String() != "Hello from opencode" {
|
||||
t.Errorf("output: got %q, want %q", output.String(), "Hello from opencode")
|
||||
}
|
||||
msg := <-ch
|
||||
if msg.Type != MessageText {
|
||||
t.Errorf("type: got %v, want MessageText", msg.Type)
|
||||
}
|
||||
if msg.Content != "Hello from opencode" {
|
||||
t.Errorf("content: got %q, want %q", msg.Content, "Hello from opencode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeHandleTextEventEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
event := opencodeEvent{
|
||||
Type: "text",
|
||||
Part: opencodeEventPart{Type: "text", Text: ""},
|
||||
}
|
||||
|
||||
b.handleTextEvent(event, ch, &output)
|
||||
|
||||
if output.String() != "" {
|
||||
t.Errorf("expected empty output, got %q", output.String())
|
||||
}
|
||||
if len(ch) != 0 {
|
||||
t.Errorf("expected no messages, got %d", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool use event tests (real opencode schema) ──
|
||||
|
||||
func TestOpencodeHandleToolUseEventCompleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
// Real opencode tool_use event: single event with state containing both
|
||||
// call parameters and result.
|
||||
event := opencodeEvent{
|
||||
Type: "tool_use",
|
||||
Part: opencodeEventPart{
|
||||
Tool: "bash",
|
||||
CallID: "call_BHA1",
|
||||
State: &opencodeToolState{
|
||||
Status: "completed",
|
||||
Input: json.RawMessage(`{"command":"pwd","description":"Prints current working directory path"}`),
|
||||
Output: "/tmp/multica\n",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.handleToolUseEvent(event, ch)
|
||||
|
||||
// Should emit both a tool-use and a tool-result message.
|
||||
if len(ch) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(ch))
|
||||
}
|
||||
|
||||
// First: tool-use
|
||||
msg := <-ch
|
||||
if msg.Type != MessageToolUse {
|
||||
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
|
||||
}
|
||||
if msg.Tool != "bash" {
|
||||
t.Errorf("tool: got %q, want %q", msg.Tool, "bash")
|
||||
}
|
||||
if msg.CallID != "call_BHA1" {
|
||||
t.Errorf("callID: got %q, want %q", msg.CallID, "call_BHA1")
|
||||
}
|
||||
if cmd, ok := msg.Input["command"].(string); !ok || cmd != "pwd" {
|
||||
t.Errorf("input.command: got %v", msg.Input["command"])
|
||||
}
|
||||
|
||||
// Second: tool-result
|
||||
msg = <-ch
|
||||
if msg.Type != MessageToolResult {
|
||||
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
|
||||
}
|
||||
if msg.CallID != "call_BHA1" {
|
||||
t.Errorf("callID: got %q, want %q", msg.CallID, "call_BHA1")
|
||||
}
|
||||
if msg.Output != "/tmp/multica\n" {
|
||||
t.Errorf("output: got %q", msg.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeHandleToolUseEventPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
// Tool use with pending status — only emit tool-use, no result.
|
||||
event := opencodeEvent{
|
||||
Type: "tool_use",
|
||||
Part: opencodeEventPart{
|
||||
Tool: "read",
|
||||
CallID: "call_ABC",
|
||||
State: &opencodeToolState{
|
||||
Status: "pending",
|
||||
Input: json.RawMessage(`{"filePath":"/tmp/test.go"}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.handleToolUseEvent(event, ch)
|
||||
|
||||
if len(ch) != 1 {
|
||||
t.Fatalf("expected 1 message for pending tool, got %d", len(ch))
|
||||
}
|
||||
msg := <-ch
|
||||
if msg.Type != MessageToolUse {
|
||||
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
|
||||
}
|
||||
if msg.Tool != "read" {
|
||||
t.Errorf("tool: got %q, want %q", msg.Tool, "read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeHandleToolUseEventStructuredOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
// Tool with structured (non-string) output.
|
||||
event := opencodeEvent{
|
||||
Type: "tool_use",
|
||||
Part: opencodeEventPart{
|
||||
Tool: "glob",
|
||||
CallID: "call_XYZ",
|
||||
State: &opencodeToolState{
|
||||
Status: "completed",
|
||||
Input: json.RawMessage(`{"pattern":"*.go"}`),
|
||||
Output: map[string]any{"files": []any{"main.go", "main_test.go"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.handleToolUseEvent(event, ch)
|
||||
|
||||
// tool-use + tool-result
|
||||
if len(ch) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(ch))
|
||||
}
|
||||
<-ch // skip tool-use
|
||||
msg := <-ch
|
||||
if msg.Type != MessageToolResult {
|
||||
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
|
||||
}
|
||||
if !strings.Contains(msg.Output, "main.go") {
|
||||
t.Errorf("output should contain 'main.go', got %q", msg.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeHandleToolUseEventNilState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
// Tool use with no state at all — should emit tool-use with no crash.
|
||||
event := opencodeEvent{
|
||||
Type: "tool_use",
|
||||
Part: opencodeEventPart{
|
||||
Tool: "write",
|
||||
CallID: "call_NUL",
|
||||
},
|
||||
}
|
||||
|
||||
b.handleToolUseEvent(event, ch)
|
||||
|
||||
if len(ch) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(ch))
|
||||
}
|
||||
msg := <-ch
|
||||
if msg.Type != MessageToolUse {
|
||||
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error event tests ──
|
||||
|
||||
func TestOpencodeHandleErrorEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
|
||||
event := opencodeEvent{
|
||||
Type: "error",
|
||||
SessionID: "ses_abc",
|
||||
Error: &opencodeError{
|
||||
Name: "UnknownError",
|
||||
Data: &opencodeErrData{
|
||||
Message: "Model not found: definitely/not-a-model.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.handleErrorEvent(event, ch, &status, &errMsg)
|
||||
|
||||
if status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", status, "failed")
|
||||
}
|
||||
if errMsg != "Model not found: definitely/not-a-model." {
|
||||
t.Errorf("error: got %q", errMsg)
|
||||
}
|
||||
msg := <-ch
|
||||
if msg.Type != MessageError {
|
||||
t.Errorf("type: got %v, want MessageError", msg.Type)
|
||||
}
|
||||
if msg.Content != "Model not found: definitely/not-a-model." {
|
||||
t.Errorf("content: got %q", msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeHandleErrorEventNameOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
|
||||
// Error with name but no data.message — should fall back to name.
|
||||
event := opencodeEvent{
|
||||
Type: "error",
|
||||
Error: &opencodeError{
|
||||
Name: "RateLimitError",
|
||||
},
|
||||
}
|
||||
|
||||
b.handleErrorEvent(event, ch, &status, &errMsg)
|
||||
|
||||
if errMsg != "RateLimitError" {
|
||||
t.Errorf("error: got %q, want %q", errMsg, "RateLimitError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeHandleErrorEventNilError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
|
||||
event := opencodeEvent{Type: "error"}
|
||||
|
||||
b.handleErrorEvent(event, ch, &status, &errMsg)
|
||||
|
||||
if errMsg != "unknown opencode error" {
|
||||
t.Errorf("error: got %q, want %q", errMsg, "unknown opencode error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON parsing tests with real fixtures ──
|
||||
|
||||
func TestOpencodeEventParsingTextFixture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
line := `{"type":"text","timestamp":1775116675833,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","type":"text","text":"pong","time":{"start":1775116675833,"end":1775116675833}}}`
|
||||
|
||||
var event opencodeEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if event.Type != "text" {
|
||||
t.Errorf("type: got %q, want %q", event.Type, "text")
|
||||
}
|
||||
if event.SessionID != "ses_abc" {
|
||||
t.Errorf("sessionID: got %q, want %q", event.SessionID, "ses_abc")
|
||||
}
|
||||
if event.Part.Text != "pong" {
|
||||
t.Errorf("part.text: got %q, want %q", event.Part.Text, "pong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeEventParsingToolUseFixture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Real `tool_use` JSON from live `opencode run --format json` output.
|
||||
line := `{"type":"tool_use","timestamp":1775117187163,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","type":"tool","tool":"bash","callID":"call_BHA1","state":{"status":"completed","input":{"command":"pwd","description":"Prints current working directory path"},"output":"/tmp/multica\n","metadata":{"exit":0},"time":{"start":1775117187092,"end":1775117187162}}}}`
|
||||
|
||||
var event opencodeEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if event.Type != "tool_use" {
|
||||
t.Errorf("type: got %q, want %q", event.Type, "tool_use")
|
||||
}
|
||||
if event.Part.Tool != "bash" {
|
||||
t.Errorf("part.tool: got %q, want %q", event.Part.Tool, "bash")
|
||||
}
|
||||
if event.Part.CallID != "call_BHA1" {
|
||||
t.Errorf("part.callID: got %q, want %q", event.Part.CallID, "call_BHA1")
|
||||
}
|
||||
if event.Part.State == nil {
|
||||
t.Fatal("part.state is nil")
|
||||
}
|
||||
if event.Part.State.Status != "completed" {
|
||||
t.Errorf("state.status: got %q, want %q", event.Part.State.Status, "completed")
|
||||
}
|
||||
|
||||
// Parse state.input
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal(event.Part.State.Input, &input); err != nil {
|
||||
t.Fatalf("unmarshal state.input: %v", err)
|
||||
}
|
||||
if input["command"] != "pwd" {
|
||||
t.Errorf("state.input.command: got %v, want %q", input["command"], "pwd")
|
||||
}
|
||||
|
||||
// state.output should be a string
|
||||
if output, ok := event.Part.State.Output.(string); !ok || output != "/tmp/multica\n" {
|
||||
t.Errorf("state.output: got %v (%T)", event.Part.State.Output, event.Part.State.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeEventParsingErrorFixture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
line := `{"type":"error","timestamp":1775117233612,"sessionID":"ses_abc","error":{"name":"UnknownError","data":{"message":"Model not found: definitely/not-a-model."}}}`
|
||||
|
||||
var event opencodeEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if event.Type != "error" {
|
||||
t.Errorf("type: got %q, want %q", event.Type, "error")
|
||||
}
|
||||
if event.Error == nil {
|
||||
t.Fatal("error field is nil")
|
||||
}
|
||||
if event.Error.Name != "UnknownError" {
|
||||
t.Errorf("error.name: got %q", event.Error.Name)
|
||||
}
|
||||
if got := event.Error.Message(); got != "Model not found: definitely/not-a-model." {
|
||||
t.Errorf("error.Message(): got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeEventParsingStepStartFixture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
line := `{"type":"step_start","timestamp":1775116675819,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","snapshot":"abc123","type":"step-start"}}`
|
||||
|
||||
var event opencodeEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if event.Type != "step_start" {
|
||||
t.Errorf("type: got %q, want %q", event.Type, "step_start")
|
||||
}
|
||||
if event.SessionID != "ses_abc" {
|
||||
t.Errorf("sessionID: got %q", event.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeStepFinishParsing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
line := `{"type":"step_finish","timestamp":1775116676180,"sessionID":"ses_abc","part":{"id":"prt_789","reason":"stop","snapshot":"abc123","messageID":"msg_456","sessionID":"ses_abc","type":"step-finish","tokens":{"total":14674,"input":14585,"output":89,"reasoning":82,"cache":{"write":0,"read":0}},"cost":0}}`
|
||||
|
||||
var event opencodeEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if event.Type != "step_finish" {
|
||||
t.Errorf("type: got %q, want %q", event.Type, "step_finish")
|
||||
}
|
||||
if event.SessionID != "ses_abc" {
|
||||
t.Errorf("sessionID: got %q", event.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── extractToolOutput tests ──
|
||||
|
||||
func TestExtractToolOutputString(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := extractToolOutput("hello\n"); got != "hello\n" {
|
||||
t.Errorf("got %q, want %q", got, "hello\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolOutputNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := extractToolOutput(nil); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolOutputStructured(t *testing.T) {
|
||||
t.Parallel()
|
||||
obj := map[string]any{"key": "value"}
|
||||
got := extractToolOutput(obj)
|
||||
if !strings.Contains(got, `"key"`) || !strings.Contains(got, `"value"`) {
|
||||
t.Errorf("got %q, expected JSON containing key/value", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── opencodeError.Message() tests ──
|
||||
|
||||
func TestOpencodeErrorMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err *opencodeError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "data message",
|
||||
err: &opencodeError{Name: "Err", Data: &opencodeErrData{Message: "details"}},
|
||||
want: "details",
|
||||
},
|
||||
{
|
||||
name: "name only",
|
||||
err: &opencodeError{Name: "RateLimitError"},
|
||||
want: "RateLimitError",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
err: &opencodeError{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.err.Message(); got != tt.want {
|
||||
t.Errorf("Message() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration-level tests: processEvents ──
|
||||
//
|
||||
// These feed multiple JSON lines through processEvents and verify the
|
||||
// accumulated result (status, output, sessionID, error) and emitted messages.
|
||||
|
||||
func TestOpencodeProcessEventsHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
// Simulate a successful run: step_start → text → tool_use → text → step_finish
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"step_start","timestamp":1000,"sessionID":"ses_happy","part":{"type":"step-start"}}`,
|
||||
`{"type":"text","timestamp":1001,"sessionID":"ses_happy","part":{"type":"text","text":"Analyzing the issue..."}}`,
|
||||
`{"type":"tool_use","timestamp":1002,"sessionID":"ses_happy","part":{"tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"ls"},"output":"file1.go\nfile2.go\n"}}}`,
|
||||
`{"type":"text","timestamp":1003,"sessionID":"ses_happy","part":{"type":"text","text":" Done."}}`,
|
||||
`{"type":"step_finish","timestamp":1004,"sessionID":"ses_happy","part":{"type":"step-finish"}}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
// Verify result.
|
||||
if result.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "completed")
|
||||
}
|
||||
if result.sessionID != "ses_happy" {
|
||||
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_happy")
|
||||
}
|
||||
if result.output != "Analyzing the issue... Done." {
|
||||
t.Errorf("output: got %q, want %q", result.output, "Analyzing the issue... Done.")
|
||||
}
|
||||
if result.errMsg != "" {
|
||||
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
||||
}
|
||||
|
||||
// Drain and verify messages.
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
|
||||
// Expected: status(running), text, tool-use, tool-result, text, = 5 messages
|
||||
if len(msgs) != 5 {
|
||||
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
|
||||
}
|
||||
if msgs[0].Type != MessageStatus || msgs[0].Status != "running" {
|
||||
t.Errorf("msg[0]: got %+v, want status=running", msgs[0])
|
||||
}
|
||||
if msgs[1].Type != MessageText || msgs[1].Content != "Analyzing the issue..." {
|
||||
t.Errorf("msg[1]: got %+v", msgs[1])
|
||||
}
|
||||
if msgs[2].Type != MessageToolUse || msgs[2].Tool != "bash" {
|
||||
t.Errorf("msg[2]: got %+v, want tool-use(bash)", msgs[2])
|
||||
}
|
||||
if msgs[3].Type != MessageToolResult || msgs[3].Output != "file1.go\nfile2.go\n" {
|
||||
t.Errorf("msg[3]: got %+v, want tool-result", msgs[3])
|
||||
}
|
||||
if msgs[4].Type != MessageText || msgs[4].Content != " Done." {
|
||||
t.Errorf("msg[4]: got %+v", msgs[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeProcessEventsErrorCausesFailedStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
// Simulate: step_start → error (model not found) → step_finish.
|
||||
// OpenCode exits RC=0 on error events, so the error event is the only
|
||||
// signal that something went wrong.
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"step_start","timestamp":1000,"sessionID":"ses_err","part":{"type":"step-start"}}`,
|
||||
`{"type":"error","timestamp":1001,"sessionID":"ses_err","error":{"name":"UnknownError","data":{"message":"Model not found: bad/model"}}}`,
|
||||
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_err","part":{"type":"step-finish"}}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "failed")
|
||||
}
|
||||
if result.errMsg != "Model not found: bad/model" {
|
||||
t.Errorf("errMsg: got %q", result.errMsg)
|
||||
}
|
||||
if result.sessionID != "ses_err" {
|
||||
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_err")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var errorMsgs int
|
||||
for m := range ch {
|
||||
if m.Type == MessageError {
|
||||
errorMsgs++
|
||||
}
|
||||
}
|
||||
if errorMsgs != 1 {
|
||||
t.Errorf("expected 1 error message, got %d", errorMsgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeProcessEventsSessionIDExtracted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
// Session ID should be captured from the last event that has one.
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"step_start","timestamp":1000,"sessionID":"ses_first","part":{"type":"step-start"}}`,
|
||||
`{"type":"text","timestamp":1001,"sessionID":"ses_updated","part":{"type":"text","text":"hi"}}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.sessionID != "ses_updated" {
|
||||
t.Errorf("sessionID: got %q, want %q (should use last seen)", result.sessionID, "ses_updated")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpencodeProcessEventsScannerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
// Use an ioErrReader that returns valid data then an I/O error, which
|
||||
// triggers scanner.Err() and should set status to "failed".
|
||||
result := b.processEvents(&ioErrReader{
|
||||
data: `{"type":"text","sessionID":"ses_scan","part":{"text":"before error"}}` + "\n",
|
||||
}, ch)
|
||||
|
||||
if result.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "failed")
|
||||
}
|
||||
if !strings.Contains(result.errMsg, "stdout read error") {
|
||||
t.Errorf("errMsg: got %q, want it to contain 'stdout read error'", result.errMsg)
|
||||
}
|
||||
// The text event before the error should still be captured.
|
||||
if result.output != "before error" {
|
||||
t.Errorf("output: got %q, want %q", result.output, "before error")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// ioErrReader delivers data on the first Read, then returns an error on the second.
|
||||
type ioErrReader struct {
|
||||
data string
|
||||
read bool
|
||||
}
|
||||
|
||||
func (r *ioErrReader) Read(p []byte) (int, error) {
|
||||
if !r.read {
|
||||
r.read = true
|
||||
n := copy(p, r.data)
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("simulated I/O error")
|
||||
}
|
||||
|
||||
func TestOpencodeProcessEventsEmptyLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
// Empty lines and invalid JSON should be skipped without error.
|
||||
lines := strings.Join([]string{
|
||||
"",
|
||||
" ",
|
||||
"not json at all",
|
||||
`{"type":"text","sessionID":"ses_ok","part":{"text":"valid"}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "completed")
|
||||
}
|
||||
if result.output != "valid" {
|
||||
t.Errorf("output: got %q, want %q", result.output, "valid")
|
||||
}
|
||||
if result.sessionID != "ses_ok" {
|
||||
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_ok")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].Type != MessageText {
|
||||
t.Errorf("expected 1 text message, got %d: %+v", len(msgs), msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
// Error event followed by more text — status should remain "failed".
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"error","sessionID":"ses_x","error":{"name":"RateLimitError"}}`,
|
||||
`{"type":"text","sessionID":"ses_x","part":{"text":"recovered?"}}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q (error should stick)", result.status, "failed")
|
||||
}
|
||||
if result.errMsg != "RateLimitError" {
|
||||
t.Errorf("errMsg: got %q, want %q", result.errMsg, "RateLimitError")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
@@ -11,6 +11,44 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const archiveAgent = `-- name: ArchiveAgent :one
|
||||
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||
`
|
||||
|
||||
type ArchiveAgentParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ArchivedBy pgtype.UUID `json:"archived_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) ArchiveAgent(ctx context.Context, arg ArchiveAgentParams) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, archiveAgent, arg.ID, arg.ArchivedBy)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const cancelAgentTask = `-- name: CancelAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled', completed_at = now()
|
||||
@@ -42,6 +80,17 @@ func (q *Queries) CancelAgentTask(ctx context.Context, id pgtype.UUID) (AgentTas
|
||||
return i, err
|
||||
}
|
||||
|
||||
const cancelAgentTasksByAgent = `-- name: CancelAgentTasksByAgent :exec
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled'
|
||||
WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running')
|
||||
`
|
||||
|
||||
func (q *Queries) CancelAgentTasksByAgent(ctx context.Context, agentID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, cancelAgentTasksByAgent, agentID)
|
||||
return err
|
||||
}
|
||||
|
||||
const cancelAgentTasksByIssue = `-- name: CancelAgentTasksByIssue :exec
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled'
|
||||
@@ -160,7 +209,7 @@ INSERT INTO agent (
|
||||
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
tools, triggers, instructions
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||
`
|
||||
|
||||
type CreateAgentParams struct {
|
||||
@@ -214,6 +263,8 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -262,15 +313,6 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAgent = `-- name: DeleteAgent :exec
|
||||
DELETE FROM agent WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAgent(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteAgent, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const failAgentTask = `-- name: FailAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'failed', completed_at = now(), error = $2
|
||||
@@ -350,7 +392,7 @@ func (q *Queries) FailStaleTasks(ctx context.Context, arg FailStaleTasksParams)
|
||||
}
|
||||
|
||||
const getAgent = `-- name: GetAgent :one
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by FROM agent
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -375,12 +417,14 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentInWorkspace = `-- name: GetAgentInWorkspace :one
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by FROM agent
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
@@ -410,6 +454,8 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -604,8 +650,8 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag
|
||||
}
|
||||
|
||||
const listAgents = `-- name: ListAgents :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions FROM agent
|
||||
WHERE workspace_id = $1
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by FROM agent
|
||||
WHERE workspace_id = $1 AND archived_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
@@ -636,6 +682,54 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAllAgents = `-- name: ListAllAgents :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by FROM agent
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Agent, error) {
|
||||
rows, err := q.db.Query(ctx, listAllAgents, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Agent{}
|
||||
for rows.Next() {
|
||||
var i Agent
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -733,6 +827,39 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const restoreAgent = `-- name: RestoreAgent :one
|
||||
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||
`
|
||||
|
||||
func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, restoreAgent, id)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const startAgentTask = `-- name: StartAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'running', started_at = now()
|
||||
@@ -780,7 +907,7 @@ UPDATE agent SET
|
||||
instructions = COALESCE($13, instructions),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||
`
|
||||
|
||||
type UpdateAgentParams struct {
|
||||
@@ -834,6 +961,8 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -841,7 +970,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||
const updateAgentStatus = `-- name: UpdateAgentStatus :one
|
||||
UPDATE agent SET status = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||
`
|
||||
|
||||
type UpdateAgentStatusParams struct {
|
||||
@@ -870,6 +999,8 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
|
||||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,8 @@ type Agent struct {
|
||||
Triggers []byte `json:"triggers"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Instructions string `json:"instructions"`
|
||||
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
|
||||
ArchivedBy pgtype.UUID `json:"archived_by"`
|
||||
}
|
||||
|
||||
type AgentRuntime struct {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
-- name: ListAgents :many
|
||||
SELECT * FROM agent
|
||||
WHERE workspace_id = $1 AND archived_at IS NULL
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ListAllAgents :many
|
||||
SELECT * FROM agent
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
@@ -37,8 +42,15 @@ UPDATE agent SET
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteAgent :exec
|
||||
DELETE FROM agent WHERE id = $1;
|
||||
-- name: ArchiveAgent :one
|
||||
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: RestoreAgent :one
|
||||
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListAgentTasks :many
|
||||
SELECT * FROM agent_task_queue
|
||||
@@ -55,6 +67,11 @@ UPDATE agent_task_queue
|
||||
SET status = 'cancelled'
|
||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
||||
|
||||
-- name: CancelAgentTasksByAgent :exec
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled'
|
||||
WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
||||
|
||||
-- name: GetAgentTask :one
|
||||
SELECT * FROM agent_task_queue
|
||||
WHERE id = $1;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,9 +17,10 @@ const (
|
||||
EventIssueReactionRemoved = "issue_reaction:removed"
|
||||
|
||||
// Agent events
|
||||
EventAgentStatus = "agent:status"
|
||||
EventAgentCreated = "agent:created"
|
||||
EventAgentDeleted = "agent:deleted"
|
||||
EventAgentStatus = "agent:status"
|
||||
EventAgentCreated = "agent:created"
|
||||
EventAgentArchived = "agent:archived"
|
||||
EventAgentRestored = "agent:restored"
|
||||
|
||||
// Task events (server <-> daemon)
|
||||
EventTaskDispatch = "task:dispatch"
|
||||
|
||||
Reference in New Issue
Block a user