merge: resolve conflicts with main
- Take main's router.go, rich-text-editor.tsx, comment-card.tsx - Remove deleted daemon_pairing.go - Keep issue mention card feature
This commit is contained in:
commit
b8c784dda3
68 changed files with 2359 additions and 1139 deletions
|
|
@ -30,6 +30,15 @@ GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||||
|
|
||||||
|
# S3 / CloudFront
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_REGION=us-west-2
|
||||||
|
CLOUDFRONT_KEY_PAIR_ID=
|
||||||
|
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
|
||||||
|
CLOUDFRONT_PRIVATE_KEY=
|
||||||
|
CLOUDFRONT_DOMAIN=
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
FRONTEND_ORIGIN=http://localhost:3000
|
FRONTEND_ORIGIN=http://localhost:3000
|
||||||
|
|
|
||||||
1
.eslintcache
Normal file
1
.eslintcache
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -314,18 +314,8 @@ Run the local daemon:
|
||||||
make daemon
|
make daemon
|
||||||
```
|
```
|
||||||
|
|
||||||
Normal flow:
|
The daemon authenticates using the CLI's stored token (`multica login`).
|
||||||
|
It registers runtimes for all watched workspaces from the CLI config.
|
||||||
1. start the daemon
|
|
||||||
2. open the pairing link it prints
|
|
||||||
3. choose the workspace in the browser
|
|
||||||
4. let the daemon register its local runtime
|
|
||||||
|
|
||||||
Debug shortcut:
|
|
||||||
|
|
||||||
- you can set `MULTICA_WORKSPACE_ID` in your env file
|
|
||||||
- this skips normal pairing
|
|
||||||
- treat it as a local shortcut, not the default workflow
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Globe,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
|
@ -1154,11 +1155,143 @@ function TasksTab({ agent }: { agent: Agent }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings Tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SettingsTab({
|
||||||
|
agent,
|
||||||
|
runtimes,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
agent: Agent;
|
||||||
|
runtimes: RuntimeDevice[];
|
||||||
|
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(agent.name);
|
||||||
|
const [description, setDescription] = useState(agent.description ?? "");
|
||||||
|
const [visibility, setVisibility] = useState<AgentVisibility>(agent.visibility);
|
||||||
|
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
name !== agent.name ||
|
||||||
|
description !== (agent.description ?? "") ||
|
||||||
|
visibility !== agent.visibility ||
|
||||||
|
maxTasks !== agent.max_concurrent_tasks;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({ name: name.trim(), description, visibility, max_concurrent_tasks: maxTasks });
|
||||||
|
toast.success("Settings saved");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save settings");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeDevice = runtimes.find((r) => r.id === agent.runtime_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What does this agent do?"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Visibility</Label>
|
||||||
|
<div className="mt-1.5 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisibility("workspace")}
|
||||||
|
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
|
||||||
|
visibility === "workspace"
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">Workspace</div>
|
||||||
|
<div className="text-xs text-muted-foreground">All members can assign</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisibility("private")}
|
||||||
|
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
|
||||||
|
visibility === "private"
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">Private</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Only you can assign</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Max Concurrent Tasks</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={maxTasks}
|
||||||
|
onChange={(e) => setMaxTasks(Number(e.target.value))}
|
||||||
|
className="mt-1 w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||||
|
<div className="mt-1 flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm text-muted-foreground">
|
||||||
|
{agent.runtime_mode === "cloud" ? (
|
||||||
|
<Cloud className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
|
||||||
|
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Save className="h-3.5 w-3.5 mr-1.5" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Agent Detail
|
// Agent Detail
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks";
|
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks" | "settings";
|
||||||
|
|
||||||
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
||||||
{ id: "instructions", label: "Instructions", icon: FileText },
|
{ id: "instructions", label: "Instructions", icon: FileText },
|
||||||
|
|
@ -1166,6 +1299,7 @@ const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
||||||
{ id: "tools", label: "Tools", icon: Wrench },
|
{ id: "tools", label: "Tools", icon: Wrench },
|
||||||
{ id: "triggers", label: "Triggers", icon: Timer },
|
{ id: "triggers", label: "Triggers", icon: Timer },
|
||||||
{ id: "tasks", label: "Tasks", icon: ListTodo },
|
{ id: "tasks", label: "Tasks", icon: ListTodo },
|
||||||
|
{ id: "settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
function AgentDetail({
|
function AgentDetail({
|
||||||
|
|
@ -1270,6 +1404,13 @@ function AgentDetail({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
||||||
|
{activeTab === "settings" && (
|
||||||
|
<SettingsTab
|
||||||
|
agent={agent}
|
||||||
|
runtimes={runtimes}
|
||||||
|
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,9 @@ function InboxListItem({
|
||||||
|
|
||||||
export default function InboxPage() {
|
export default function InboxPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const selectedId = searchParams.get("id") ?? "";
|
const selectedKey = searchParams.get("issue") ?? "";
|
||||||
const setSelectedId = (id: string) => {
|
const setSelectedKey = (key: string) => {
|
||||||
const url = id ? `/inbox?id=${id}` : "/inbox";
|
const url = key ? `/inbox?issue=${key}` : "/inbox";
|
||||||
window.history.replaceState(null, "", url);
|
window.history.replaceState(null, "", url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -232,12 +232,12 @@ export default function InboxPage() {
|
||||||
id: "multica_inbox_layout",
|
id: "multica_inbox_layout",
|
||||||
});
|
});
|
||||||
|
|
||||||
const selected = items.find((i) => i.id === selectedId) ?? null;
|
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
|
||||||
const unreadCount = items.filter((i) => !i.read).length;
|
const unreadCount = items.filter((i) => !i.read).length;
|
||||||
|
|
||||||
// Click-to-read: select + auto-mark-read
|
// Click-to-read: select + auto-mark-read
|
||||||
const handleSelect = async (item: InboxItem) => {
|
const handleSelect = async (item: InboxItem) => {
|
||||||
setSelectedId(item.id);
|
setSelectedKey(item.issue_id ?? item.id);
|
||||||
if (!item.read) {
|
if (!item.read) {
|
||||||
useInboxStore.getState().markRead(item.id);
|
useInboxStore.getState().markRead(item.id);
|
||||||
try {
|
try {
|
||||||
|
|
@ -254,7 +254,8 @@ export default function InboxPage() {
|
||||||
try {
|
try {
|
||||||
await api.archiveInbox(id);
|
await api.archiveInbox(id);
|
||||||
useInboxStore.getState().archive(id);
|
useInboxStore.getState().archive(id);
|
||||||
if (selectedId === id) setSelectedId("");
|
const archived = items.find((i) => i.id === id);
|
||||||
|
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to archive");
|
toast.error("Failed to archive");
|
||||||
}
|
}
|
||||||
|
|
@ -274,7 +275,7 @@ export default function InboxPage() {
|
||||||
const handleArchiveAll = async () => {
|
const handleArchiveAll = async () => {
|
||||||
try {
|
try {
|
||||||
useInboxStore.getState().archiveAll();
|
useInboxStore.getState().archiveAll();
|
||||||
setSelectedId("");
|
setSelectedKey("");
|
||||||
await api.archiveAllInbox();
|
await api.archiveAllInbox();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to archive all");
|
toast.error("Failed to archive all");
|
||||||
|
|
@ -284,9 +285,9 @@ export default function InboxPage() {
|
||||||
|
|
||||||
const handleArchiveAllRead = async () => {
|
const handleArchiveAllRead = async () => {
|
||||||
try {
|
try {
|
||||||
const readIds = items.filter((i) => i.read).map((i) => i.id);
|
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||||
useInboxStore.getState().archiveAllRead();
|
useInboxStore.getState().archiveAllRead();
|
||||||
if (readIds.includes(selectedId)) setSelectedId("");
|
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||||
await api.archiveAllReadInbox();
|
await api.archiveAllReadInbox();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to archive read items");
|
toast.error("Failed to archive read items");
|
||||||
|
|
@ -297,7 +298,7 @@ export default function InboxPage() {
|
||||||
const handleArchiveCompleted = async () => {
|
const handleArchiveCompleted = async () => {
|
||||||
try {
|
try {
|
||||||
await api.archiveCompletedInbox();
|
await api.archiveCompletedInbox();
|
||||||
setSelectedId("");
|
setSelectedKey("");
|
||||||
await useInboxStore.getState().fetch();
|
await useInboxStore.getState().fetch();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to archive completed");
|
toast.error("Failed to archive completed");
|
||||||
|
|
@ -395,7 +396,7 @@ export default function InboxPage() {
|
||||||
<InboxListItem
|
<InboxListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
isSelected={item.id === selectedId}
|
isSelected={(item.issue_id ?? item.id) === selectedKey}
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
onArchive={() => handleArchive(item.id)}
|
onArchive={() => handleArchive(item.id)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ vi.mock("@/features/workspace", () => ({
|
||||||
if (type === "agent") return "CA";
|
if (type === "agent") return "CA";
|
||||||
return "??";
|
return "??";
|
||||||
},
|
},
|
||||||
|
getActorAvatarUrl: () => null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -296,6 +297,7 @@ describe("IssueDetailPage", () => {
|
||||||
author_id: "user-1",
|
author_id: "user-1",
|
||||||
parent_id: null,
|
parent_id: null,
|
||||||
reactions: [],
|
reactions: [],
|
||||||
|
attachments: [],
|
||||||
created_at: "2026-01-18T00:00:00Z",
|
created_at: "2026-01-18T00:00:00Z",
|
||||||
updated_at: "2026-01-18T00:00:00Z",
|
updated_at: "2026-01-18T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ vi.mock("@/features/workspace", () => ({
|
||||||
getActorName: (type: string, id: string) =>
|
getActorName: (type: string, id: string) =>
|
||||||
type === "member" ? "Test User" : "Claude Agent",
|
type === "member" ? "Test User" : "Claude Agent",
|
||||||
getActorInitials: () => "TU",
|
getActorInitials: () => "TU",
|
||||||
|
getActorAvatarUrl: () => null,
|
||||||
}),
|
}),
|
||||||
useWorkspaceStore: Object.assign(
|
useWorkspaceStore: Object.assign(
|
||||||
(selector?: any) => {
|
(selector?: any) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Save } from "lucide-react";
|
import { Camera, Loader2, Save } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -9,27 +9,48 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/features/auth";
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
|
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||||
|
|
||||||
export function AccountTab() {
|
export function AccountTab() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const setUser = useAuthStore((s) => s.setUser);
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
|
|
||||||
const [profileName, setProfileName] = useState(user?.name ?? "");
|
const [profileName, setProfileName] = useState(user?.name ?? "");
|
||||||
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
|
|
||||||
const [profileSaving, setProfileSaving] = useState(false);
|
const [profileSaving, setProfileSaving] = useState(false);
|
||||||
|
const { upload, uploading } = useFileUpload();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProfileName(user?.name ?? "");
|
setProfileName(user?.name ?? "");
|
||||||
setAvatarUrl(user?.avatar_url ?? "");
|
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const initials = (user?.name ?? "")
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
// Reset input so the same file can be re-selected
|
||||||
|
e.target.value = "";
|
||||||
|
try {
|
||||||
|
const result = await upload(file);
|
||||||
|
if (!result) return;
|
||||||
|
const updated = await api.updateMe({ avatar_url: result.link });
|
||||||
|
setUser(updated);
|
||||||
|
toast.success("Avatar updated");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to upload avatar");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleProfileSave = async () => {
|
const handleProfileSave = async () => {
|
||||||
setProfileSaving(true);
|
setProfileSaving(true);
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateMe({
|
const updated = await api.updateMe({ name: profileName });
|
||||||
name: profileName,
|
|
||||||
avatar_url: avatarUrl || undefined,
|
|
||||||
});
|
|
||||||
setUser(updated);
|
setUser(updated);
|
||||||
toast.success("Profile updated");
|
toast.success("Profile updated");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -45,7 +66,46 @@ export function AccountTab() {
|
||||||
<h2 className="text-sm font-semibold">Profile</h2>
|
<h2 className="text-sm font-semibold">Profile</h2>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-4">
|
||||||
|
{/* Avatar upload */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group relative h-16 w-16 shrink-0 rounded-full bg-muted overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{user?.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar_url}
|
||||||
|
alt={user.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-full w-full items-center justify-center text-lg font-semibold text-muted-foreground">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||||||
|
) : (
|
||||||
|
<Camera className="h-5 w-5 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Click to upload avatar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -55,16 +115,6 @@ export function AccountTab() {
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">Avatar URL</Label>
|
|
||||||
<Input
|
|
||||||
type="url"
|
|
||||||
value={avatarUrl}
|
|
||||||
onChange={(e) => setAvatarUrl(e.target.value)}
|
|
||||||
placeholder="https://example.com/avatar.png"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-1">
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,8 @@
|
||||||
--warning: oklch(0.75 0.16 85);
|
--warning: oklch(0.75 0.16 85);
|
||||||
--info: oklch(0.55 0.18 250);
|
--info: oklch(0.55 0.18 250);
|
||||||
--priority: oklch(0.65 0.18 50);
|
--priority: oklch(0.65 0.18 50);
|
||||||
--scrollbar-thumb: oklch(0.82 0.003 286);
|
--scrollbar-thumb: oklch(0 0 0 / 10%);
|
||||||
--scrollbar-thumb-hover: oklch(0.705 0.015 286.067);
|
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
|
||||||
--scrollbar-track: transparent;
|
--scrollbar-track: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,8 +140,8 @@
|
||||||
--warning: oklch(0.70 0.16 85);
|
--warning: oklch(0.70 0.16 85);
|
||||||
--info: oklch(0.65 0.18 250);
|
--info: oklch(0.65 0.18 250);
|
||||||
--priority: oklch(0.70 0.18 50);
|
--priority: oklch(0.70 0.18 50);
|
||||||
--scrollbar-thumb: oklch(1 0 0 / 15%);
|
--scrollbar-thumb: oklch(1 0 0 / 8%);
|
||||||
--scrollbar-thumb-hover: oklch(1 0 0 / 30%);
|
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);
|
||||||
--scrollbar-track: transparent;
|
--scrollbar-track: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
|
|
||||||
const {
|
|
||||||
mockGetDaemonPairingSession,
|
|
||||||
mockApproveDaemonPairingSession,
|
|
||||||
mockWorkspace,
|
|
||||||
mockAuthValue,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
mockGetDaemonPairingSession: vi.fn(),
|
|
||||||
mockApproveDaemonPairingSession: vi.fn(),
|
|
||||||
mockWorkspace: {
|
|
||||||
id: "05ce77f1-7c45-4735-b1f7-619347f7f76c",
|
|
||||||
name: "Jiayuan's Workspace",
|
|
||||||
slug: "jiayuan-05ce77f1",
|
|
||||||
description: null,
|
|
||||||
settings: {},
|
|
||||||
created_at: "2026-03-24T00:00:00Z",
|
|
||||||
updated_at: "2026-03-24T00:00:00Z",
|
|
||||||
},
|
|
||||||
mockAuthValue: {
|
|
||||||
user: {
|
|
||||||
id: "user-1",
|
|
||||||
name: "Jiayuan",
|
|
||||||
email: "jiayuan@example.com",
|
|
||||||
avatar_url: null,
|
|
||||||
created_at: "2026-03-24T00:00:00Z",
|
|
||||||
updated_at: "2026-03-24T00:00:00Z",
|
|
||||||
},
|
|
||||||
workspaces: [] as Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
description: null;
|
|
||||||
settings: Record<string, never>;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}>,
|
|
||||||
workspace: null as null | {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
description: null;
|
|
||||||
settings: Record<string, never>;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
mockAuthValue.workspaces = [mockWorkspace];
|
|
||||||
mockAuthValue.workspace = mockWorkspace;
|
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useSearchParams: () => new URLSearchParams("token=test-token"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/shared/api", () => ({
|
|
||||||
api: {
|
|
||||||
getDaemonPairingSession: mockGetDaemonPairingSession,
|
|
||||||
approveDaemonPairingSession: mockApproveDaemonPairingSession,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/auth", () => ({
|
|
||||||
useAuthStore: (selector: (s: any) => any) =>
|
|
||||||
selector(mockAuthValue),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/workspace", () => ({
|
|
||||||
useWorkspaceStore: (selector: (s: any) => any) =>
|
|
||||||
selector(mockAuthValue),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import LocalDaemonPairPage from "./page";
|
|
||||||
|
|
||||||
describe("LocalDaemonPairPage", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockGetDaemonPairingSession.mockResolvedValue({
|
|
||||||
token: "test-token",
|
|
||||||
daemon_id: "local-daemon",
|
|
||||||
device_name: "Jiayuans-MacBook-Pro.local",
|
|
||||||
runtime_name: "Local Codex",
|
|
||||||
runtime_type: "codex",
|
|
||||||
runtime_version: "codex-cli 0.116.0",
|
|
||||||
workspace_id: mockWorkspace.id,
|
|
||||||
status: "pending",
|
|
||||||
approved_at: null,
|
|
||||||
claimed_at: null,
|
|
||||||
expires_at: "2026-03-24T07:20:00Z",
|
|
||||||
link_url: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the selected workspace name instead of the raw id", async () => {
|
|
||||||
render(<LocalDaemonPairPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockGetDaemonPairingSession).toHaveBeenCalledWith("test-token");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText("Jiayuan's Workspace")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(mockWorkspace.id)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import type { DaemonPairingSession } from "@/shared/types";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { api } from "@/shared/api";
|
|
||||||
import { useAuthStore } from "@/features/auth";
|
|
||||||
import { useWorkspaceStore } from "@/features/workspace";
|
|
||||||
|
|
||||||
function formatExpiresAt(value: string) {
|
|
||||||
return new Date(value).toLocaleString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function LocalDaemonPairPageContent() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const token = searchParams.get("token") ?? "";
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
|
||||||
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
|
||||||
const [session, setSession] = useState<DaemonPairingSession | null>(null);
|
|
||||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const nextLoginURL = useMemo(() => {
|
|
||||||
const next = `/pair/local?token=${encodeURIComponent(token)}`;
|
|
||||||
return `/login?next=${encodeURIComponent(next)}`;
|
|
||||||
}, [token]);
|
|
||||||
const selectedWorkspace = useMemo(
|
|
||||||
() => workspaces.find((item) => item.id === selectedWorkspaceId) ?? null,
|
|
||||||
[selectedWorkspaceId, workspaces],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!token) {
|
|
||||||
setError("Missing pairing token.");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
api.getDaemonPairingSession(token)
|
|
||||||
.then((value) => {
|
|
||||||
setSession(value);
|
|
||||||
setSelectedWorkspaceId(value.workspace_id || workspace?.id || workspaces[0]?.id || "");
|
|
||||||
})
|
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load pairing session."))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [token, workspace?.id, workspaces]);
|
|
||||||
|
|
||||||
const approve = async () => {
|
|
||||||
if (!token || !selectedWorkspaceId) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const approved = await api.approveDaemonPairingSession(token, {
|
|
||||||
workspace_id: selectedWorkspaceId,
|
|
||||||
});
|
|
||||||
setSession(approved);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to approve pairing session.");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-canvas px-6 py-12">
|
|
||||||
<div className="w-full max-w-xl rounded-2xl border bg-background p-8 shadow-sm">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Connect Local Codex Runtime</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Approve this pairing request to register your local Codex runtime with a workspace.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading || isLoading ? (
|
|
||||||
<div className="mt-8 text-sm text-muted-foreground">Loading pairing session...</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="mt-8 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : session ? (
|
|
||||||
<>
|
|
||||||
<div className="mt-6 rounded-xl border bg-muted/30 p-4">
|
|
||||||
<div className="text-sm font-medium">{session.runtime_name}</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{session.device_name}
|
|
||||||
{session.runtime_version ? ` · ${session.runtime_version}` : ""}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
{session.runtime_type}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-xs text-muted-foreground">
|
|
||||||
Expires {formatExpiresAt(session.expires_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!user ? (
|
|
||||||
<div className="mt-6 space-y-3">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Sign in first, then choose which workspace should own this local runtime.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href={nextLoginURL}
|
|
||||||
className="inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Sign in to continue
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : session.status === "approved" || session.status === "claimed" ? (
|
|
||||||
<div className="mt-6 rounded-xl border border-success/30 bg-success/5 px-4 py-3 text-sm text-success">
|
|
||||||
This runtime is linked to a workspace. Return to the daemon window to finish setup.
|
|
||||||
</div>
|
|
||||||
) : session.status === "expired" ? (
|
|
||||||
<div className="mt-6 rounded-xl border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
|
|
||||||
This pairing link expired. Restart the daemon to generate a new link.
|
|
||||||
</div>
|
|
||||||
) : workspaces.length === 0 ? (
|
|
||||||
<div className="mt-6 rounded-xl border px-4 py-3 text-sm text-muted-foreground">
|
|
||||||
You do not have a workspace yet. Create one first, then reopen this pairing link.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label className="mb-2">Workspace</Label>
|
|
||||||
<Select value={selectedWorkspaceId} onValueChange={(v) => setSelectedWorkspaceId(v ?? "")}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<span className="flex flex-1 min-w-0 truncate text-left">
|
|
||||||
{selectedWorkspace?.name ?? "Select workspace"}
|
|
||||||
</span>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{workspaces.map((item) => (
|
|
||||||
<SelectItem key={item.id} value={item.id}>
|
|
||||||
{item.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={approve}
|
|
||||||
disabled={submitting || !selectedWorkspaceId}
|
|
||||||
>
|
|
||||||
{submitting ? "Registering..." : "Register runtime"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LocalDaemonPairPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<LocalDaemonPairPageContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { Bot } from "lucide-react";
|
import { Bot } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useActorName } from "@/features/workspace";
|
import { useActorName } from "@/features/workspace";
|
||||||
|
|
@ -8,8 +9,10 @@ interface ActorAvatarProps {
|
||||||
actorType: string;
|
actorType: string;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
avatarUrl?: string | null;
|
||||||
getName?: (type: string, id: string) => string;
|
getName?: (type: string, id: string) => string;
|
||||||
getInitials?: (type: string, id: string) => string;
|
getInitials?: (type: string, id: string) => string;
|
||||||
|
getAvatarUrl?: (type: string, id: string) => string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,29 +20,47 @@ function ActorAvatar({
|
||||||
actorType,
|
actorType,
|
||||||
actorId,
|
actorId,
|
||||||
size = 20,
|
size = 20,
|
||||||
|
avatarUrl,
|
||||||
getName,
|
getName,
|
||||||
getInitials,
|
getInitials,
|
||||||
|
getAvatarUrl,
|
||||||
className,
|
className,
|
||||||
}: ActorAvatarProps) {
|
}: ActorAvatarProps) {
|
||||||
const actorNameHook = useActorName();
|
const actorNameHook = useActorName();
|
||||||
const resolveName = getName ?? actorNameHook.getActorName;
|
const resolveName = getName ?? actorNameHook.getActorName;
|
||||||
const resolveInitials = getInitials ?? actorNameHook.getActorInitials;
|
const resolveInitials = getInitials ?? actorNameHook.getActorInitials;
|
||||||
|
const resolveAvatarUrl = getAvatarUrl ?? actorNameHook.getActorAvatarUrl;
|
||||||
|
|
||||||
const name = resolveName(actorType, actorId);
|
const name = resolveName(actorType, actorId);
|
||||||
const initials = resolveInitials(actorType, actorId);
|
const initials = resolveInitials(actorType, actorId);
|
||||||
const isAgent = actorType === "agent";
|
const isAgent = actorType === "agent";
|
||||||
|
const resolvedUrl = avatarUrl !== undefined ? avatarUrl : resolveAvatarUrl(actorType, actorId);
|
||||||
|
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
|
// Reset error state when URL changes (e.g. user uploads new avatar)
|
||||||
|
useEffect(() => {
|
||||||
|
setImgError(false);
|
||||||
|
}, [resolvedUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex shrink-0 items-center justify-center rounded-full font-medium",
|
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
|
||||||
"bg-muted text-muted-foreground",
|
"bg-muted text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ width: size, height: size, fontSize: size * 0.45 }}
|
style={{ width: size, height: size, fontSize: size * 0.45 }}
|
||||||
title={name}
|
title={name}
|
||||||
>
|
>
|
||||||
{isAgent ? (
|
{resolvedUrl && !imgError ? (
|
||||||
|
<img
|
||||||
|
src={resolvedUrl}
|
||||||
|
alt={name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
) : isAgent ? (
|
||||||
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
|
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||||
) : (
|
) : (
|
||||||
initials
|
initials
|
||||||
|
|
|
||||||
70
apps/web/components/common/mention-hover-card.tsx
Normal file
70
apps/web/components/common/mention-hover-card.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Bot } from "lucide-react";
|
||||||
|
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||||
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
|
import { useWorkspaceStore } from "@/features/workspace";
|
||||||
|
|
||||||
|
interface MentionHoverCardProps {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
|
||||||
|
const members = useWorkspaceStore((s) => s.members);
|
||||||
|
const agents = useWorkspaceStore((s) => s.agents);
|
||||||
|
|
||||||
|
if (type === "member") {
|
||||||
|
const member = members.find((m) => m.user_id === id);
|
||||||
|
if (!member) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger render={<span />} className="cursor-default">
|
||||||
|
{children}
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<ActorAvatar actorType="member" actorId={id} size={32} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{member.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "agent") {
|
||||||
|
const agent = agents.find((a) => a.id === id);
|
||||||
|
if (!agent) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger render={<span />} className="cursor-default">
|
||||||
|
{children}
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{agent.name}</p>
|
||||||
|
{agent.description && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MentionHoverCard };
|
||||||
|
|
@ -126,7 +126,11 @@
|
||||||
|
|
||||||
/* Links */
|
/* Links */
|
||||||
.rich-text-editor a {
|
.rich-text-editor a {
|
||||||
color: var(--primary);
|
color: var(--brand);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
@ -134,11 +138,9 @@
|
||||||
/* Mentions */
|
/* Mentions */
|
||||||
.rich-text-editor .mention {
|
.rich-text-editor .mention {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
background: color-mix(in srgb, var(--primary) 8%, transparent);
|
font-weight: 600;
|
||||||
padding: 0 0.2em;
|
|
||||||
border-radius: calc(var(--radius) * 0.5);
|
|
||||||
font-weight: 500;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
margin: 0 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Strong / emphasis */
|
/* Strong / emphasis */
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,12 @@ import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
import Typography from "@tiptap/extension-typography";
|
import Typography from "@tiptap/extension-typography";
|
||||||
import Mention from "@tiptap/extension-mention";
|
import Mention from "@tiptap/extension-mention";
|
||||||
|
import Image from "@tiptap/extension-image";
|
||||||
import { Markdown } from "@tiptap/markdown";
|
import { Markdown } from "@tiptap/markdown";
|
||||||
import { Extension } from "@tiptap/core";
|
import { Extension, mergeAttributes } from "@tiptap/core";
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||||
import { createMentionSuggestion } from "./mention-suggestion";
|
import { createMentionSuggestion } from "./mention-suggestion";
|
||||||
import "./rich-text-editor.css";
|
import "./rich-text-editor.css";
|
||||||
|
|
||||||
|
|
@ -30,56 +33,22 @@ interface RichTextEditorProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
|
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RichTextEditorRef {
|
interface RichTextEditorRef {
|
||||||
getMarkdown: () => string;
|
getMarkdown: () => string;
|
||||||
clearContent: () => void;
|
clearContent: () => void;
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
|
insertFile: (filename: string, url: string, isImage: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Submit shortcut extension (Mod+Enter)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mention extension configured for markdown serialization
|
|
||||||
// Stores as: [@Label](mention://type/id)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Link extension — always serialize as [text](url), never <url> autolinks;
|
|
||||||
// support Cmd+Click / Ctrl+Click to open in new tab.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const LinkExtension = Link.configure({
|
const LinkExtension = Link.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
autolink: true,
|
autolink: true,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "text-primary hover:underline cursor-pointer",
|
class: "text-primary hover:underline cursor-pointer",
|
||||||
},
|
},
|
||||||
}).extend({
|
|
||||||
addStorage() {
|
|
||||||
return {
|
|
||||||
markdown: {
|
|
||||||
serialize: {
|
|
||||||
open() {
|
|
||||||
return "[";
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
close(_state: any, mark: any) {
|
|
||||||
const href = (mark.attrs.href as string).replace(/[\(\)"]/g, "\\$&");
|
|
||||||
const title = mark.attrs.title
|
|
||||||
? ` "${(mark.attrs.title as string).replace(/"/g, '\\"')}"`
|
|
||||||
: "";
|
|
||||||
return `](${href}${title})`;
|
|
||||||
},
|
|
||||||
mixable: true,
|
|
||||||
},
|
|
||||||
parse: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const MentionExtension = Mention.configure({
|
const MentionExtension = Mention.configure({
|
||||||
|
|
@ -87,17 +56,18 @@ const MentionExtension = Mention.configure({
|
||||||
suggestion: createMentionSuggestion(),
|
suggestion: createMentionSuggestion(),
|
||||||
}).extend({
|
}).extend({
|
||||||
renderHTML({ node, HTMLAttributes }) {
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
const type = node.attrs.type ?? "member";
|
|
||||||
const label = node.attrs.label ?? node.attrs.id;
|
|
||||||
return [
|
return [
|
||||||
"a",
|
"span",
|
||||||
|
mergeAttributes(
|
||||||
|
{ "data-type": "mention" },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
{
|
{
|
||||||
...HTMLAttributes,
|
"data-mention-type": node.attrs.type ?? "member",
|
||||||
href: `mention://${type}/${node.attrs.id}`,
|
|
||||||
"data-mention-type": type,
|
|
||||||
"data-mention-id": node.attrs.id,
|
"data-mention-id": node.attrs.id,
|
||||||
},
|
},
|
||||||
type === "issue" ? label : `@${label}`,
|
),
|
||||||
|
`@${node.attrs.label ?? node.attrs.id}`,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
|
|
@ -105,21 +75,39 @@ const MentionExtension = Mention.configure({
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
type: {
|
type: {
|
||||||
default: "member",
|
default: "member",
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member",
|
parseHTML: (el: HTMLElement) =>
|
||||||
},
|
el.getAttribute("data-mention-type") ?? "member",
|
||||||
description: {
|
renderHTML: () => ({}),
|
||||||
default: null,
|
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// @tiptap/markdown 3.x uses renderMarkdown as a top-level extension field
|
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
|
||||||
|
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], id: match[3] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
renderMarkdown(node: any) {
|
parseMarkdown: (token: any, helpers: any) => {
|
||||||
const type = node.attrs?.type ?? "member";
|
return helpers.createNode("mention", token.attributes);
|
||||||
const label = node.attrs?.label ?? node.attrs?.id;
|
},
|
||||||
const display = type === "issue" ? label : `@${label}`;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return `[${display}](mention://${type}/${node.attrs?.id})`;
|
renderMarkdown: (node: any) => {
|
||||||
|
const { id, label, type = "member" } = node.attrs || {};
|
||||||
|
return `[@${label ?? id}](mention://${type}/${id})`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -141,6 +129,77 @@ function createSubmitExtension(onSubmit: () => void) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File upload extension (paste + drop)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
const result = await handler(file);
|
||||||
|
if (!result) continue;
|
||||||
|
|
||||||
|
const isImage = file.type.startsWith("image/");
|
||||||
|
if (isImage) {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({ src: result.link, alt: result.filename })
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
// Insert as a markdown link
|
||||||
|
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
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -155,26 +214,25 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
||||||
className,
|
className,
|
||||||
debounceMs = 300,
|
debounceMs = 300,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onUploadFile,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const onUpdateRef = useRef(onUpdate);
|
const onUpdateRef = useRef(onUpdate);
|
||||||
const onSubmitRef = useRef(onSubmit);
|
const onSubmitRef = useRef(onSubmit);
|
||||||
|
const onUploadFileRef = useRef(onUploadFile);
|
||||||
// Helper to get markdown from @tiptap/markdown extension
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const getEditorMarkdown = (ed: any): string =>
|
|
||||||
ed?.getMarkdown?.() ?? "";
|
|
||||||
|
|
||||||
// Keep refs in sync without recreating editor
|
// Keep refs in sync without recreating editor
|
||||||
onUpdateRef.current = onUpdate;
|
onUpdateRef.current = onUpdate;
|
||||||
onSubmitRef.current = onSubmit;
|
onSubmitRef.current = onSubmit;
|
||||||
|
onUploadFileRef.current = onUploadFile;
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
editable,
|
editable,
|
||||||
content: defaultValue,
|
content: defaultValue || "",
|
||||||
|
contentType: defaultValue ? "markdown" : undefined,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: { levels: [1, 2, 3] },
|
heading: { levels: [1, 2, 3] },
|
||||||
|
|
@ -186,14 +244,20 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
Typography,
|
Typography,
|
||||||
MentionExtension,
|
MentionExtension,
|
||||||
|
Image.configure({
|
||||||
|
inline: false,
|
||||||
|
allowBase64: false,
|
||||||
|
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
|
||||||
|
}),
|
||||||
Markdown,
|
Markdown,
|
||||||
createSubmitExtension(() => onSubmitRef.current?.()),
|
createSubmitExtension(() => onSubmitRef.current?.()),
|
||||||
|
createFileUploadExtension(onUploadFileRef),
|
||||||
],
|
],
|
||||||
onUpdate: ({ editor: ed }) => {
|
onUpdate: ({ editor: ed }) => {
|
||||||
if (!onUpdateRef.current) return;
|
if (!onUpdateRef.current) return;
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
onUpdateRef.current?.(getEditorMarkdown(ed));
|
onUpdateRef.current?.(ed.getMarkdown());
|
||||||
}, debounceMs);
|
}, debounceMs);
|
||||||
},
|
},
|
||||||
editorProps: {
|
editorProps: {
|
||||||
|
|
@ -225,13 +289,21 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
getMarkdown: () => getEditorMarkdown(editor),
|
getMarkdown: () => editor?.getMarkdown() ?? "",
|
||||||
clearContent: () => {
|
clearContent: () => {
|
||||||
editor?.commands.clearContent();
|
editor?.commands.clearContent();
|
||||||
},
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
editor?.commands.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;
|
if (!editor) return null;
|
||||||
|
|
|
||||||
18
apps/web/components/common/title-editor.css
Normal file
18
apps/web/components/common/title-editor.css
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/* Title editor: minimal ProseMirror for single-line titles */
|
||||||
|
|
||||||
|
.title-editor.ProseMirror {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-editor.ProseMirror p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.title-editor .is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
141
apps/web/components/common/title-editor.tsx
Normal file
141
apps/web/components/common/title-editor.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||||
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import "./title-editor.css";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TitleEditorProps {
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
onBlur?: (value: string) => void;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TitleEditorRef {
|
||||||
|
getText: () => string;
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single-paragraph document — prevents Enter from creating new lines
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SingleLineDocument = Document.extend({
|
||||||
|
content: "paragraph",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keyboard shortcuts: Enter → submit, Escape → blur
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createTitleKeymap(opts: {
|
||||||
|
onSubmitRef: React.RefObject<(() => void) | undefined>;
|
||||||
|
}) {
|
||||||
|
return Extension.create({
|
||||||
|
name: "titleKeymap",
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
Enter: ({ editor }) => {
|
||||||
|
opts.onSubmitRef.current?.();
|
||||||
|
editor.commands.blur();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
"Shift-Enter": () => true, // swallow — no line breaks
|
||||||
|
Escape: ({ editor }) => {
|
||||||
|
editor.commands.blur();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
|
||||||
|
function TitleEditor(
|
||||||
|
{
|
||||||
|
defaultValue = "",
|
||||||
|
placeholder: placeholderText = "",
|
||||||
|
className,
|
||||||
|
autoFocus = false,
|
||||||
|
onSubmit,
|
||||||
|
onBlur,
|
||||||
|
onChange,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const onSubmitRef = useRef(onSubmit);
|
||||||
|
const onBlurRef = useRef(onBlur);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
|
||||||
|
onSubmitRef.current = onSubmit;
|
||||||
|
onBlurRef.current = onBlur;
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
immediatelyRender: false,
|
||||||
|
content: `<p>${defaultValue}</p>`,
|
||||||
|
extensions: [
|
||||||
|
SingleLineDocument,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: placeholderText,
|
||||||
|
showOnlyCurrent: false,
|
||||||
|
}),
|
||||||
|
createTitleKeymap({ onSubmitRef }),
|
||||||
|
],
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: cn("title-editor outline-none", className),
|
||||||
|
role: "textbox",
|
||||||
|
"aria-multiline": "false",
|
||||||
|
"aria-label": placeholderText || "Title",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor: ed }) => {
|
||||||
|
onChangeRef.current?.(ed.getText());
|
||||||
|
},
|
||||||
|
onBlur: ({ editor: ed }) => {
|
||||||
|
onBlurRef.current?.(ed.getText());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-focus after mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus && editor) {
|
||||||
|
// Move cursor to end
|
||||||
|
editor.commands.focus("end");
|
||||||
|
}
|
||||||
|
}, [autoFocus, editor]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getText: () => editor?.getText() ?? "",
|
||||||
|
focus: () => {
|
||||||
|
editor?.commands.focus("end");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
return <EditorContent editor={editor} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export { TitleEditor, type TitleEditorProps, type TitleEditorRef };
|
||||||
|
|
@ -66,6 +66,15 @@ function createComponents(
|
||||||
onFileClick?: (path: string) => void
|
onFileClick?: (path: string) => void
|
||||||
): Partial<Components> {
|
): Partial<Components> {
|
||||||
const baseComponents: Partial<Components> = {
|
const baseComponents: Partial<Components> = {
|
||||||
|
// Images: render uploaded images with constrained sizing
|
||||||
|
img: ({ src, alt }) => (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt ?? ""}
|
||||||
|
className="max-w-full h-auto rounded-md my-2"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
),
|
||||||
// Links: Make clickable with callbacks, or render as mention
|
// Links: Make clickable with callbacks, or render as mention
|
||||||
a: ({ href, children }) => {
|
a: ({ href, children }) => {
|
||||||
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
|
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
|
||||||
|
|
@ -76,10 +85,7 @@ function createComponents(
|
||||||
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
|
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span className="text-primary font-semibold mx-0.5">
|
||||||
className="text-primary font-medium"
|
|
||||||
style={{ background: 'color-mix(in srgb, var(--primary) 8%, transparent)', padding: '0 0.2em', borderRadius: 'calc(var(--radius) * 0.5)' }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { X, Trash2, Bot, UserMinus } from "lucide-react";
|
import { X, Trash2, Bot, Lock, UserMinus } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,8 +19,9 @@ import {
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import type { UpdateIssueRequest } from "@/shared/types";
|
import type { Agent, UpdateIssueRequest } from "@/shared/types";
|
||||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||||
|
import { useAuthStore } from "@/features/auth";
|
||||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||||
import { useIssueStore } from "@/features/issues/store";
|
import { useIssueStore } from "@/features/issues/store";
|
||||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||||
|
|
@ -206,6 +207,13 @@ export function BatchActionToolbar() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
|
||||||
|
if (agent.visibility !== "private") return true;
|
||||||
|
if (agent.owner_id === userId) return true;
|
||||||
|
if (memberRole === "owner" || memberRole === "admin") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function BatchAssigneePicker({
|
function BatchAssigneePicker({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -218,10 +226,14 @@ function BatchAssigneePicker({
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
const members = useWorkspaceStore((s) => s.members);
|
const members = useWorkspaceStore((s) => s.members);
|
||||||
const agents = useWorkspaceStore((s) => s.agents);
|
const agents = useWorkspaceStore((s) => s.agents);
|
||||||
const { getActorInitials } = useActorName();
|
const { getActorInitials } = useActorName();
|
||||||
|
|
||||||
|
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||||
|
const memberRole = currentMember?.role;
|
||||||
|
|
||||||
const query = filter.toLowerCase();
|
const query = filter.toLowerCase();
|
||||||
const filteredMembers = members.filter((m) =>
|
const filteredMembers = members.filter((m) =>
|
||||||
m.name.toLowerCase().includes(query),
|
m.name.toLowerCase().includes(query),
|
||||||
|
|
@ -297,22 +309,30 @@ function BatchAssigneePicker({
|
||||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Agents
|
Agents
|
||||||
</div>
|
</div>
|
||||||
{filteredAgents.map((a) => (
|
{filteredAgents.map((a) => {
|
||||||
|
const allowed = canAssignAgent(a, user?.id, memberRole);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={!allowed}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!allowed) return;
|
||||||
onUpdate({ assignee_type: "agent", assignee_id: a.id });
|
onUpdate({ assignee_type: "agent", assignee_id: a.id });
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${allowed ? "hover:bg-accent" : "opacity-50 cursor-not-allowed"}`}
|
||||||
>
|
>
|
||||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
|
||||||
<Bot className="size-2.5" />
|
<Bot className="size-2.5" />
|
||||||
</div>
|
</div>
|
||||||
<span>{a.name}</span>
|
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
|
||||||
|
{a.visibility === "private" && (
|
||||||
|
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Copy, MoreHorizontal, Pencil, Trash2, ChevronRight } from "lucide-react";
|
import { ChevronRight, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -16,10 +16,10 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||||
import { Markdown } from "@/components/markdown";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useActorName } from "@/features/workspace";
|
import { useActorName } from "@/features/workspace";
|
||||||
import { timeAgo } from "@/shared/utils";
|
import { timeAgo } from "@/shared/utils";
|
||||||
|
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||||
import { ReplyInput } from "./reply-input";
|
import { ReplyInput } from "./reply-input";
|
||||||
import type { TimelineEntry } from "@/shared/types";
|
import type { TimelineEntry } from "@/shared/types";
|
||||||
|
|
||||||
|
|
@ -28,6 +28,7 @@ import type { TimelineEntry } from "@/shared/types";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface CommentCardProps {
|
interface CommentCardProps {
|
||||||
|
issueId: string;
|
||||||
entry: TimelineEntry;
|
entry: TimelineEntry;
|
||||||
allReplies: Map<string, TimelineEntry[]>;
|
allReplies: Map<string, TimelineEntry[]>;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
|
|
@ -56,28 +57,28 @@ function CommentRow({
|
||||||
}) {
|
}) {
|
||||||
const { getActorName } = useActorName();
|
const { getActorName } = useActorName();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editContent, setEditContent] = useState("");
|
const editEditorRef = useRef<RichTextEditorRef>(null);
|
||||||
|
|
||||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||||
const isTemp = entry.id.startsWith("temp-");
|
const isTemp = entry.id.startsWith("temp-");
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
setEditContent(entry.content ?? "");
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const cancelEdit = () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setEditContent("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEdit = async () => {
|
const saveEdit = async () => {
|
||||||
const trimmed = editContent.trim();
|
const trimmed = editEditorRef.current
|
||||||
|
?.getMarkdown()
|
||||||
|
?.replace(/(\n\s*)+$/, "")
|
||||||
|
.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
try {
|
try {
|
||||||
await onEdit(entry.id, trimmed);
|
await onEdit(entry.id, trimmed);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setEditContent("");
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update comment");
|
toast.error("Failed to update comment");
|
||||||
}
|
}
|
||||||
|
|
@ -142,27 +143,28 @@ function CommentRow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<form
|
<div
|
||||||
onSubmit={(e) => { e.preventDefault(); saveEdit(); }}
|
|
||||||
className="mt-2 pl-8"
|
className="mt-2 pl-8"
|
||||||
>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={editContent}
|
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
|
||||||
aria-label="Edit comment"
|
|
||||||
className="w-full text-sm bg-transparent border-b border-border outline-none py-1"
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||||
|
>
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
|
||||||
|
<RichTextEditor
|
||||||
|
ref={editEditorRef}
|
||||||
|
defaultValue={entry.content ?? ""}
|
||||||
|
placeholder="Edit comment..."
|
||||||
|
onSubmit={saveEdit}
|
||||||
|
debounceMs={100}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 mt-1.5">
|
|
||||||
<Button size="sm" type="submit">Save</Button>
|
|
||||||
<Button size="sm" variant="ghost" type="button" onClick={cancelEdit}>Cancel</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div className="flex gap-2 mt-1.5">
|
||||||
|
<Button size="sm" onClick={saveEdit}>Save</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
||||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
<RichTextEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||||
</div>
|
</div>
|
||||||
{!isTemp && (
|
{!isTemp && (
|
||||||
<ReactionBar
|
<ReactionBar
|
||||||
|
|
@ -183,6 +185,7 @@ function CommentRow({
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function CommentCard({
|
function CommentCard({
|
||||||
|
issueId,
|
||||||
entry,
|
entry,
|
||||||
allReplies,
|
allReplies,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
|
@ -194,28 +197,28 @@ function CommentCard({
|
||||||
const { getActorName } = useActorName();
|
const { getActorName } = useActorName();
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editContent, setEditContent] = useState("");
|
const editEditorRef = useRef<RichTextEditorRef>(null);
|
||||||
|
|
||||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||||
const isTemp = entry.id.startsWith("temp-");
|
const isTemp = entry.id.startsWith("temp-");
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
setEditContent(entry.content ?? "");
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const cancelEdit = () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setEditContent("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEdit = async () => {
|
const saveEdit = async () => {
|
||||||
const trimmed = editContent.trim();
|
const trimmed = editEditorRef.current
|
||||||
|
?.getMarkdown()
|
||||||
|
?.replace(/(\n\s*)+$/, "")
|
||||||
|
.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
try {
|
try {
|
||||||
await onEdit(entry.id, trimmed);
|
await onEdit(entry.id, trimmed);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setEditContent("");
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update comment");
|
toast.error("Failed to update comment");
|
||||||
}
|
}
|
||||||
|
|
@ -242,17 +245,17 @@ function CommentCard({
|
||||||
{/* Header — always visible, acts as toggle */}
|
{/* Header — always visible, acts as toggle */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<CollapsibleTrigger className="shrink-0 text-muted-foreground hover:text-foreground transition-colors">
|
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
||||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
|
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} />
|
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} />
|
||||||
<span className="text-sm font-medium">
|
<span className="shrink-0 text-sm font-medium">
|
||||||
{getActorName(entry.actor_type, entry.actor_id)}
|
{getActorName(entry.actor_type, entry.actor_id)}
|
||||||
</span>
|
</span>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
<span className="text-xs text-muted-foreground cursor-default">
|
<span className="shrink-0 text-xs text-muted-foreground cursor-default">
|
||||||
{timeAgo(entry.created_at)}
|
{timeAgo(entry.created_at)}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
@ -263,12 +266,12 @@ function CommentCard({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{!open && contentPreview && (
|
{!open && contentPreview && (
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||||
{contentPreview}{(entry.content ?? "").length > 80 ? "..." : ""}
|
{contentPreview}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!open && replyCount > 0 && (
|
{!open && replyCount > 0 && (
|
||||||
<span className="text-xs text-muted-foreground shrink-0 ml-auto">
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
{replyCount} {replyCount === 1 ? "reply" : "replies"}
|
{replyCount} {replyCount === 1 ? "reply" : "replies"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -315,27 +318,28 @@ function CommentCard({
|
||||||
{/* Parent comment body */}
|
{/* Parent comment body */}
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<form
|
<div
|
||||||
onSubmit={(e) => { e.preventDefault(); saveEdit(); }}
|
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={editContent}
|
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
|
||||||
aria-label="Edit comment"
|
|
||||||
className="w-full text-sm bg-transparent border-b border-border outline-none py-1"
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||||
|
>
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
|
||||||
|
<RichTextEditor
|
||||||
|
ref={editEditorRef}
|
||||||
|
defaultValue={entry.content ?? ""}
|
||||||
|
placeholder="Edit comment..."
|
||||||
|
onSubmit={saveEdit}
|
||||||
|
debounceMs={100}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 mt-1.5">
|
|
||||||
<Button size="sm" type="submit">Save</Button>
|
|
||||||
<Button size="sm" variant="ghost" type="button" onClick={cancelEdit}>Cancel</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div className="flex gap-2 mt-1.5">
|
||||||
|
<Button size="sm" onClick={saveEdit}>Save</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
||||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
<RichTextEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||||
</div>
|
</div>
|
||||||
{!isTemp && (
|
{!isTemp && (
|
||||||
<ReactionBar
|
<ReactionBar
|
||||||
|
|
@ -365,6 +369,7 @@ function CommentCard({
|
||||||
{/* Reply input */}
|
{/* Reply input */}
|
||||||
<div className="border-t border-border/50 px-4 py-2.5">
|
<div className="border-t border-border/50 px-4 py-2.5">
|
||||||
<ReplyInput
|
<ReplyInput
|
||||||
|
issueId={issueId}
|
||||||
placeholder="Leave a reply..."
|
placeholder="Leave a reply..."
|
||||||
size="sm"
|
size="sm"
|
||||||
avatarType="member"
|
avatarType="member"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,34 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { ArrowUp } from "lucide-react";
|
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||||
|
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||||
|
|
||||||
interface CommentInputProps {
|
interface CommentInputProps {
|
||||||
|
issueId: string;
|
||||||
onSubmit: (content: string) => Promise<void>;
|
onSubmit: (content: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentInput({ onSubmit }: CommentInputProps) {
|
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||||
const editorRef = useRef<RichTextEditorRef>(null);
|
const editorRef = useRef<RichTextEditorRef>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isEmpty, setIsEmpty] = useState(true);
|
const [isEmpty, setIsEmpty] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const { uploadWithToast, uploading } = useFileUpload();
|
||||||
|
|
||||||
|
const handleUpload = (file: File) => uploadWithToast(file, { issueId });
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = "";
|
||||||
|
const result = await handleUpload(file);
|
||||||
|
if (result) {
|
||||||
|
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||||
|
|
@ -35,16 +51,32 @@ function CommentInput({ onSubmit }: CommentInputProps) {
|
||||||
placeholder="Leave a comment..."
|
placeholder="Leave a comment..."
|
||||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
onUploadFile={handleUpload}
|
||||||
debounceMs={100}
|
debounceMs={100}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-1.5 right-1.5">
|
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={isEmpty || submitting}
|
disabled={isEmpty || submitting}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-4 w-4" />
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef, memo } from "react";
|
import { useState, useEffect, useCallback, memo } from "react";
|
||||||
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
|
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
@ -43,8 +43,8 @@ import {
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { RichTextEditor } from "@/components/common/rich-text-editor";
|
import { RichTextEditor } from "@/components/common/rich-text-editor";
|
||||||
|
import { TitleEditor } from "@/components/common/title-editor";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
|
|
@ -69,6 +69,7 @@ import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
|
||||||
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
|
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
|
||||||
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
||||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||||
|
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||||
import { timeAgo } from "@/shared/utils";
|
import { timeAgo } from "@/shared/utils";
|
||||||
|
|
||||||
function shortDate(date: string | null): string {
|
function shortDate(date: string | null): string {
|
||||||
|
|
@ -179,14 +180,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
|
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
|
||||||
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
|
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
|
||||||
const { getActorName, getActorInitials } = useActorName();
|
const { getActorName, getActorInitials } = useActorName();
|
||||||
|
const { uploadWithToast } = useFileUpload();
|
||||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||||
id: layoutId,
|
id: layoutId,
|
||||||
});
|
});
|
||||||
const sidebarRef = usePanelRef();
|
const sidebarRef = usePanelRef();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen);
|
const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [titleDraft, setTitleDraft] = useState("");
|
|
||||||
const titleFocusedRef = useRef(false);
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
||||||
const [detailsOpen, setDetailsOpen] = useState(true);
|
const [detailsOpen, setDetailsOpen] = useState(true);
|
||||||
|
|
@ -211,13 +211,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
.finally(() => setIssueLoading(false));
|
.finally(() => setIssueLoading(false));
|
||||||
}, [id, !!issue]);
|
}, [id, !!issue]);
|
||||||
|
|
||||||
// Sync titleDraft when issue title changes (from WS or other views)
|
|
||||||
useEffect(() => {
|
|
||||||
if (issue && !titleFocusedRef.current) {
|
|
||||||
setTitleDraft(issue.title);
|
|
||||||
}
|
|
||||||
}, [issue?.title]);
|
|
||||||
|
|
||||||
// Custom hooks — encapsulate timeline, reactions, subscribers
|
// Custom hooks — encapsulate timeline, reactions, subscribers
|
||||||
const {
|
const {
|
||||||
timeline, submitting, submitComment, submitReply,
|
timeline, submitting, submitComment, submitReply,
|
||||||
|
|
@ -249,6 +242,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
[issue, id],
|
[issue, id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDescriptionUpload = useCallback(
|
||||||
|
(file: File) => uploadWithToast(file, { issueId: id }),
|
||||||
|
[uploadWithToast, id],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -547,26 +545,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
{/* Content — scrollable */}
|
{/* Content — scrollable */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto w-full max-w-4xl px-8 py-8">
|
<div className="mx-auto w-full max-w-4xl px-8 py-8">
|
||||||
<input
|
<TitleEditor
|
||||||
value={titleDraft}
|
key={`title-${id}`}
|
||||||
onChange={(e) => setTitleDraft(e.target.value)}
|
defaultValue={issue.title}
|
||||||
onFocus={() => { titleFocusedRef.current = true; }}
|
placeholder="Issue title"
|
||||||
onBlur={() => {
|
className="w-full text-2xl font-bold leading-snug tracking-tight"
|
||||||
titleFocusedRef.current = false;
|
onBlur={(value) => {
|
||||||
const trimmed = titleDraft.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed });
|
if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed });
|
||||||
else setTitleDraft(issue.title);
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
(e.target as HTMLInputElement).blur();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setTitleDraft(issue.title);
|
|
||||||
(e.target as HTMLInputElement).blur();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full bg-transparent text-2xl font-bold leading-snug tracking-tight outline-none placeholder:text-muted-foreground"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
|
|
@ -574,6 +561,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
defaultValue={issue.description || ""}
|
defaultValue={issue.description || ""}
|
||||||
placeholder="Add description..."
|
placeholder="Add description..."
|
||||||
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
|
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
|
||||||
|
onUploadFile={handleDescriptionUpload}
|
||||||
debounceMs={1500}
|
debounceMs={1500}
|
||||||
className="mt-5"
|
className="mt-5"
|
||||||
/>
|
/>
|
||||||
|
|
@ -741,6 +729,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
return (
|
return (
|
||||||
<CommentCard
|
<CommentCard
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
|
issueId={id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
allReplies={repliesByParent}
|
allReplies={repliesByParent}
|
||||||
currentUserId={user?.id}
|
currentUserId={user?.id}
|
||||||
|
|
@ -803,7 +792,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
|
|
||||||
{/* Bottom comment input — no avatar, full width */}
|
{/* Bottom comment input — no avatar, full width */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<CommentInput onSubmit={submitComment} />
|
<CommentInput issueId={id} onSubmit={submitComment} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { ArrowUp } from "lucide-react";
|
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||||
|
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ReplyInputProps {
|
interface ReplyInputProps {
|
||||||
|
issueId: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
avatarType: string;
|
avatarType: string;
|
||||||
avatarId: string;
|
avatarId: string;
|
||||||
|
|
@ -23,6 +25,7 @@ interface ReplyInputProps {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function ReplyInput({
|
function ReplyInput({
|
||||||
|
issueId,
|
||||||
placeholder = "Leave a reply...",
|
placeholder = "Leave a reply...",
|
||||||
avatarType,
|
avatarType,
|
||||||
avatarId,
|
avatarId,
|
||||||
|
|
@ -30,8 +33,22 @@ function ReplyInput({
|
||||||
size = "default",
|
size = "default",
|
||||||
}: ReplyInputProps) {
|
}: ReplyInputProps) {
|
||||||
const editorRef = useRef<RichTextEditorRef>(null);
|
const editorRef = useRef<RichTextEditorRef>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isEmpty, setIsEmpty] = useState(true);
|
const [isEmpty, setIsEmpty] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const { uploadWithToast, uploading } = useFileUpload();
|
||||||
|
|
||||||
|
const handleUpload = (file: File) => uploadWithToast(file, { issueId });
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = "";
|
||||||
|
const result = await handleUpload(file);
|
||||||
|
if (result) {
|
||||||
|
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||||
|
|
@ -67,6 +84,7 @@ function ReplyInput({
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
onUploadFile={handleUpload}
|
||||||
debounceMs={100}
|
debounceMs={100}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,14 +94,30 @@ function ReplyInput({
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="flex items-center justify-end pt-1">
|
<div className="flex items-center justify-end gap-1 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
tabIndex={isEmpty ? -1 : 0}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
disabled={isEmpty || submitting}
|
disabled={isEmpty || submitting}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
tabIndex={isEmpty ? -1 : 0}
|
tabIndex={isEmpty ? -1 : 0}
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-3.5 w-3.5" />
|
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUp className="h-3.5 w-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -176,27 +176,14 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
const submitComment = useCallback(
|
const submitComment = useCallback(
|
||||||
async (content: string) => {
|
async (content: string) => {
|
||||||
if (!content.trim() || submitting || !userId) return;
|
if (!content.trim() || submitting || !userId) return;
|
||||||
const tempId = "temp-" + Date.now();
|
|
||||||
const tempEntry: TimelineEntry = {
|
|
||||||
type: "comment",
|
|
||||||
id: tempId,
|
|
||||||
actor_type: "member",
|
|
||||||
actor_id: userId,
|
|
||||||
content,
|
|
||||||
parent_id: null,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
comment_type: "comment",
|
|
||||||
};
|
|
||||||
setTimeline((prev) => [...prev, tempEntry]);
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const comment = await api.createComment(issueId, content);
|
const comment = await api.createComment(issueId, content);
|
||||||
setTimeline((prev) =>
|
setTimeline((prev) => {
|
||||||
prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e)),
|
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||||
);
|
return [...prev, commentToTimelineEntry(comment)];
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setTimeline((prev) => prev.filter((e) => e.id !== tempId));
|
|
||||||
toast.error("Failed to send comment");
|
toast.error("Failed to send comment");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
@ -208,26 +195,13 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||||
const submitReply = useCallback(
|
const submitReply = useCallback(
|
||||||
async (parentId: string, content: string) => {
|
async (parentId: string, content: string) => {
|
||||||
if (!content.trim() || !userId) return;
|
if (!content.trim() || !userId) return;
|
||||||
const tempId = "temp-" + Date.now();
|
|
||||||
const tempEntry: TimelineEntry = {
|
|
||||||
type: "comment",
|
|
||||||
id: tempId,
|
|
||||||
actor_type: "member",
|
|
||||||
actor_id: userId,
|
|
||||||
content,
|
|
||||||
parent_id: parentId,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
comment_type: "comment",
|
|
||||||
};
|
|
||||||
setTimeline((prev) => [...prev, tempEntry]);
|
|
||||||
try {
|
try {
|
||||||
const comment = await api.createComment(issueId, content, "comment", parentId);
|
const comment = await api.createComment(issueId, content, "comment", parentId);
|
||||||
setTimeline((prev) =>
|
setTimeline((prev) => {
|
||||||
prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e)),
|
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||||
);
|
return [...prev, commentToTimelineEntry(comment)];
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setTimeline((prev) => prev.filter((e) => e.id !== tempId));
|
|
||||||
toast.error("Failed to send reply");
|
toast.error("Failed to send reply");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ import {
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||||
|
import { TitleEditor } from "@/components/common/title-editor";
|
||||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||||
|
|
@ -186,19 +186,13 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="px-5 pb-2 shrink-0">
|
<div className="px-5 pb-2 shrink-0">
|
||||||
<Input
|
<TitleEditor
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
defaultValue={draft.title}
|
||||||
value={title}
|
|
||||||
onChange={(e) => updateTitle(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Issue title"
|
placeholder="Issue title"
|
||||||
className="border-none shadow-none px-0 text-lg font-semibold focus-visible:ring-0 dark:bg-transparent"
|
className="text-lg font-semibold"
|
||||||
|
onChange={(v) => updateTitle(v)}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ import { useWorkspaceStore } from "@/features/workspace";
|
||||||
import { createLogger } from "@/shared/logger";
|
import { createLogger } from "@/shared/logger";
|
||||||
import { useRealtimeSync } from "./use-realtime-sync";
|
import { useRealtimeSync } from "./use-realtime-sync";
|
||||||
|
|
||||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
|
const WS_URL =
|
||||||
|
process.env.NEXT_PUBLIC_WS_URL ||
|
||||||
|
(typeof window !== "undefined"
|
||||||
|
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
|
||||||
|
: "ws://localhost:8080/ws");
|
||||||
|
|
||||||
type EventHandler = (payload: unknown) => void;
|
type EventHandler = (payload: unknown) => void;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,5 +32,11 @@ export function useActorName() {
|
||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { getMemberName, getAgentName, getActorName, getActorInitials };
|
const getActorAvatarUrl = (type: string, id: string): string | null => {
|
||||||
|
if (type === "member") return members.find((m) => m.user_id === id)?.avatar_url ?? null;
|
||||||
|
if (type === "agent") return agents.find((a) => a.id === id)?.avatar_url ?? null;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getMemberName, getAgentName, getActorName, getActorInitials, getActorAvatarUrl };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const remoteApiUrl = process.env.REMOTE_API_URL ?? "https://multica-api.copilothub.ai";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${remoteApiUrl}/api/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/ws",
|
||||||
|
destination: `${remoteApiUrl}/ws`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/auth/:path*",
|
||||||
|
destination: `${remoteApiUrl}/auth/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@floating-ui/dom": "^1.7.6",
|
"@floating-ui/dom": "^1.7.6",
|
||||||
|
"@tiptap/extension-image": "^3.20.5",
|
||||||
"@tiptap/extension-link": "^3.20.5",
|
"@tiptap/extension-link": "^3.20.5",
|
||||||
"@tiptap/extension-mention": "^3.20.5",
|
"@tiptap/extension-mention": "^3.20.5",
|
||||||
"@tiptap/extension-placeholder": "^3.20.5",
|
"@tiptap/extension-placeholder": "^3.20.5",
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ import type {
|
||||||
UpdateAgentRequest,
|
UpdateAgentRequest,
|
||||||
AgentTask,
|
AgentTask,
|
||||||
AgentRuntime,
|
AgentRuntime,
|
||||||
DaemonPairingSession,
|
|
||||||
ApproveDaemonPairingSessionRequest,
|
|
||||||
InboxItem,
|
InboxItem,
|
||||||
IssueSubscriber,
|
IssueSubscriber,
|
||||||
Comment,
|
Comment,
|
||||||
|
|
@ -35,6 +33,7 @@ import type {
|
||||||
RuntimePing,
|
RuntimePing,
|
||||||
TimelineEntry,
|
TimelineEntry,
|
||||||
TaskMessagePayload,
|
TaskMessagePayload,
|
||||||
|
Attachment,
|
||||||
} from "@/shared/types";
|
} from "@/shared/types";
|
||||||
import { type Logger, noopLogger } from "@/shared/logger";
|
import { type Logger, noopLogger } from "@/shared/logger";
|
||||||
|
|
||||||
|
|
@ -62,32 +61,15 @@ export class ApiClient {
|
||||||
this.workspaceId = id;
|
this.workspaceId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
private authHeaders(): Record<string, string> {
|
||||||
const rid = crypto.randomUUID().slice(0, 8);
|
const headers: Record<string, string> = {};
|
||||||
const start = Date.now();
|
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
||||||
const method = init?.method ?? "GET";
|
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
|
||||||
|
return headers;
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Request-ID": rid,
|
|
||||||
...((init?.headers as Record<string, string>) ?? {}),
|
|
||||||
};
|
|
||||||
if (this.token) {
|
|
||||||
headers["Authorization"] = `Bearer ${this.token}`;
|
|
||||||
}
|
|
||||||
if (this.workspaceId) {
|
|
||||||
headers["X-Workspace-ID"] = this.workspaceId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`→ ${method} ${path}`, { rid });
|
private handleUnauthorized() {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 401 && typeof window !== "undefined") {
|
|
||||||
localStorage.removeItem("multica_token");
|
localStorage.removeItem("multica_token");
|
||||||
localStorage.removeItem("multica_workspace_id");
|
localStorage.removeItem("multica_workspace_id");
|
||||||
this.token = null;
|
this.token = null;
|
||||||
|
|
@ -96,16 +78,41 @@ export class ApiClient {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let message = `API error: ${res.status} ${res.statusText}`;
|
private async parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const data = await res.json() as { error?: string };
|
const data = await res.json() as { error?: string };
|
||||||
if (typeof data.error === "string" && data.error) {
|
if (typeof data.error === "string" && data.error) return data.error;
|
||||||
message = data.error;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore non-JSON error bodies.
|
// Ignore non-JSON error bodies.
|
||||||
}
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const rid = crypto.randomUUID().slice(0, 8);
|
||||||
|
const start = Date.now();
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Request-ID": rid,
|
||||||
|
...this.authHeaders(),
|
||||||
|
...((init?.headers as Record<string, string>) ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.info(`→ ${method} ${path}`, { rid });
|
||||||
|
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) this.handleUnauthorized();
|
||||||
|
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
|
||||||
this.logger.error(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
this.logger.error(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
@ -352,20 +359,6 @@ export class ApiClient {
|
||||||
return this.fetch(`/api/issues/${issueId}/task-runs`);
|
return this.fetch(`/api/issues/${issueId}/task-runs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDaemonPairingSession(token: string): Promise<DaemonPairingSession> {
|
|
||||||
return this.fetch(`/api/daemon/pairing-sessions/${token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async approveDaemonPairingSession(
|
|
||||||
token: string,
|
|
||||||
data: ApproveDaemonPairingSessionRequest,
|
|
||||||
): Promise<DaemonPairingSession> {
|
|
||||||
return this.fetch(`/api/daemon/pairing-sessions/${token}/approve`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inbox
|
// Inbox
|
||||||
async listInbox(): Promise<InboxItem[]> {
|
async listInbox(): Promise<InboxItem[]> {
|
||||||
return this.fetch("/api/inbox");
|
return this.fetch("/api/inbox");
|
||||||
|
|
@ -519,4 +512,41 @@ export class ApiClient {
|
||||||
async revokePersonalAccessToken(id: string): Promise<void> {
|
async revokePersonalAccessToken(id: string): Promise<void> {
|
||||||
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File Upload & Attachments
|
||||||
|
async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise<Attachment> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
||||||
|
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
||||||
|
|
||||||
|
const rid = crypto.randomUUID().slice(0, 8);
|
||||||
|
const start = Date.now();
|
||||||
|
this.logger.info("→ POST /api/upload-file", { rid });
|
||||||
|
|
||||||
|
const res = await fetch(`${this.baseUrl}/api/upload-file`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.authHeaders(),
|
||||||
|
body: formData,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) this.handleUnauthorized();
|
||||||
|
const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`);
|
||||||
|
this.logger.error(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
|
||||||
|
return res.json() as Promise<Attachment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAttachments(issueId: string): Promise<Attachment[]> {
|
||||||
|
return this.fetch(`/api/issues/${issueId}/attachments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAttachment(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export { ApiClient } from "./client";
|
||||||
export type { LoginResponse } from "./client";
|
export type { LoginResponse } from "./client";
|
||||||
export { WSClient } from "./ws-client";
|
export { WSClient } from "./ws-client";
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
||||||
|
|
||||||
export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") });
|
export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") });
|
||||||
|
|
||||||
|
|
|
||||||
83
apps/web/shared/hooks/use-file-upload.ts
Normal file
83
apps/web/shared/hooks/use-file-upload.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { api } from "@/shared/api";
|
||||||
|
import type { Attachment } from "@/shared/types";
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = new Set([
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/svg+xml",
|
||||||
|
"application/pdf",
|
||||||
|
"text/plain",
|
||||||
|
"text/csv",
|
||||||
|
"application/json",
|
||||||
|
"video/mp4",
|
||||||
|
"video/webm",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/wav",
|
||||||
|
"application/zip",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isAllowedType(type: string): boolean {
|
||||||
|
// Empty MIME type (browser couldn't determine) — let the server sniff and decide.
|
||||||
|
if (!type) return true;
|
||||||
|
const mediaType = type.split(";")[0] ?? "";
|
||||||
|
return ALLOWED_TYPES.has(mediaType.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
filename: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadContext {
|
||||||
|
issueId?: string;
|
||||||
|
commentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileUpload() {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const upload = useCallback(
|
||||||
|
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
throw new Error("File exceeds 10 MB limit");
|
||||||
|
}
|
||||||
|
if (!isAllowedType(file.type)) {
|
||||||
|
throw new Error(`File type not allowed: ${file.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const att: Attachment = await api.uploadFile(file, {
|
||||||
|
issueId: ctx?.issueId,
|
||||||
|
commentId: ctx?.commentId,
|
||||||
|
});
|
||||||
|
return { filename: att.filename, link: att.url };
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadWithToast = useCallback(
|
||||||
|
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
||||||
|
try {
|
||||||
|
return await upload(file, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Upload failed");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[upload],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { upload, uploadWithToast, uploading };
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Reaction } from "./comment";
|
import type { Reaction } from "./comment";
|
||||||
|
import type { Attachment } from "./attachment";
|
||||||
|
|
||||||
export interface TimelineEntry {
|
export interface TimelineEntry {
|
||||||
type: "activity" | "comment";
|
type: "activity" | "comment";
|
||||||
|
|
@ -15,4 +16,5 @@ export interface TimelineEntry {
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
comment_type?: string;
|
comment_type?: string;
|
||||||
reactions?: Reaction[];
|
reactions?: Reaction[];
|
||||||
|
attachments?: Attachment[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
apps/web/shared/types/attachment.ts
Normal file
14
apps/web/shared/types/attachment.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
export interface Attachment {
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
issue_id: string | null;
|
||||||
|
comment_id: string | null;
|
||||||
|
uploader_type: string;
|
||||||
|
uploader_id: string;
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
download_url: string;
|
||||||
|
content_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ export interface Comment {
|
||||||
type: CommentType;
|
type: CommentType;
|
||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
reactions: Reaction[];
|
reactions: Reaction[];
|
||||||
|
attachments: import("./attachment").Attachment[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
export type DaemonPairingSessionStatus = "pending" | "approved" | "claimed" | "expired";
|
|
||||||
|
|
||||||
export interface DaemonPairingSession {
|
|
||||||
token: string;
|
|
||||||
daemon_id: string;
|
|
||||||
device_name: string;
|
|
||||||
runtime_name: string;
|
|
||||||
runtime_type: string;
|
|
||||||
runtime_version: string;
|
|
||||||
workspace_id: string | null;
|
|
||||||
status: DaemonPairingSessionStatus;
|
|
||||||
approved_at: string | null;
|
|
||||||
claimed_at: string | null;
|
|
||||||
expires_at: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
link_url?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApproveDaemonPairingSessionRequest {
|
|
||||||
workspace_id: string;
|
|
||||||
}
|
|
||||||
|
|
@ -27,6 +27,6 @@ export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||||
export type { TimelineEntry } from "./activity";
|
export type { TimelineEntry } from "./activity";
|
||||||
export type { IssueSubscriber } from "./subscriber";
|
export type { IssueSubscriber } from "./subscriber";
|
||||||
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon";
|
|
||||||
export type * from "./events";
|
export type * from "./events";
|
||||||
export type * from "./api";
|
export type * from "./api";
|
||||||
|
export type { Attachment } from "./attachment";
|
||||||
|
|
|
||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
|
|
@ -75,6 +75,9 @@ importers:
|
||||||
'@floating-ui/dom':
|
'@floating-ui/dom':
|
||||||
specifier: ^1.7.6
|
specifier: ^1.7.6
|
||||||
version: 1.7.6
|
version: 1.7.6
|
||||||
|
'@tiptap/extension-image':
|
||||||
|
specifier: ^3.20.5
|
||||||
|
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
|
||||||
'@tiptap/extension-link':
|
'@tiptap/extension-link':
|
||||||
specifier: ^3.20.5
|
specifier: ^3.20.5
|
||||||
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
|
||||||
|
|
@ -1368,6 +1371,11 @@ packages:
|
||||||
'@tiptap/core': ^3.20.5
|
'@tiptap/core': ^3.20.5
|
||||||
'@tiptap/pm': ^3.20.5
|
'@tiptap/pm': ^3.20.5
|
||||||
|
|
||||||
|
'@tiptap/extension-image@3.20.5':
|
||||||
|
resolution: {integrity: sha512-qxKupWKhX75Xc9GJ9Uel+KIFL9x6tb8W3RvQM1UolyJX/H7wyBO7sXp9XmKRkHZsDXRgLVbnkYBe+X83o16AIA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.5
|
||||||
|
|
||||||
'@tiptap/extension-italic@3.20.5':
|
'@tiptap/extension-italic@3.20.5':
|
||||||
resolution: {integrity: sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w==}
|
resolution: {integrity: sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -4949,6 +4957,10 @@ snapshots:
|
||||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||||
'@tiptap/pm': 3.20.5
|
'@tiptap/pm': 3.20.5
|
||||||
|
|
||||||
|
'@tiptap/extension-image@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 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-italic@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"github.com/multica-ai/multica/server/internal/events"
|
"github.com/multica-ai/multica/server/internal/events"
|
||||||
"github.com/multica-ai/multica/server/internal/handler"
|
"github.com/multica-ai/multica/server/internal/handler"
|
||||||
|
|
@ -13,15 +12,12 @@ import (
|
||||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mention represents a parsed @mention from markdown content.
|
// mention represents a parsed @mention from markdown content (local alias).
|
||||||
type mention struct {
|
type mention struct {
|
||||||
Type string // "member" or "agent"
|
Type string // "member" or "agent"
|
||||||
ID string // user_id or agent_id
|
ID string // user_id or agent_id
|
||||||
}
|
}
|
||||||
|
|
||||||
// mentionRe matches [@Label](mention://type/id) in markdown.
|
|
||||||
var mentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`)
|
|
||||||
|
|
||||||
// statusLabels maps DB status values to human-readable labels for notifications.
|
// statusLabels maps DB status values to human-readable labels for notifications.
|
||||||
var statusLabels = map[string]string{
|
var statusLabels = map[string]string{
|
||||||
"backlog": "Backlog",
|
"backlog": "Backlog",
|
||||||
|
|
@ -59,17 +55,12 @@ func priorityLabel(p string) string {
|
||||||
var emptyDetails = []byte("{}")
|
var emptyDetails = []byte("{}")
|
||||||
|
|
||||||
// parseMentions extracts mentions from markdown content.
|
// parseMentions extracts mentions from markdown content.
|
||||||
|
// Delegates to the shared util.ParseMentions and converts to the local type.
|
||||||
func parseMentions(content string) []mention {
|
func parseMentions(content string) []mention {
|
||||||
matches := mentionRe.FindAllStringSubmatch(content, -1)
|
parsed := util.ParseMentions(content)
|
||||||
seen := make(map[string]bool)
|
result := make([]mention, len(parsed))
|
||||||
var result []mention
|
for i, m := range parsed {
|
||||||
for _, m := range matches {
|
result[i] = mention{Type: m.Type, ID: m.ID}
|
||||||
key := m[1] + ":" + m[2]
|
|
||||||
if seen[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = true
|
|
||||||
result = append(result, mention{Type: m[1], ID: m[2]})
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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/events"
|
||||||
"github.com/multica-ai/multica/server/internal/handler"
|
"github.com/multica-ai/multica/server/internal/handler"
|
||||||
"github.com/multica-ai/multica/server/internal/middleware"
|
"github.com/multica-ai/multica/server/internal/middleware"
|
||||||
"github.com/multica-ai/multica/server/internal/realtime"
|
"github.com/multica-ai/multica/server/internal/realtime"
|
||||||
"github.com/multica-ai/multica/server/internal/service"
|
"github.com/multica-ai/multica/server/internal/service"
|
||||||
|
"github.com/multica-ai/multica/server/internal/storage"
|
||||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -47,7 +49,9 @@ func allowedOrigins() []string {
|
||||||
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
|
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
|
||||||
queries := db.New(pool)
|
queries := db.New(pool)
|
||||||
emailSvc := service.NewEmailService()
|
emailSvc := service.NewEmailService()
|
||||||
h := handler.New(queries, pool, hub, bus, emailSvc)
|
s3 := storage.NewS3StorageFromEnv()
|
||||||
|
cfSigner := auth.NewCloudFrontSignerFromEnv()
|
||||||
|
h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
|
@ -79,16 +83,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||||
r.Post("/auth/send-code", h.SendCode)
|
r.Post("/auth/send-code", h.SendCode)
|
||||||
r.Post("/auth/verify-code", h.VerifyCode)
|
r.Post("/auth/verify-code", h.VerifyCode)
|
||||||
|
|
||||||
// Daemon API routes
|
// Daemon API routes (all require a valid token)
|
||||||
r.Route("/api/daemon", func(r chi.Router) {
|
r.Route("/api/daemon", func(r chi.Router) {
|
||||||
// Pairing routes — no auth required (daemon doesn't have a token yet).
|
r.Use(middleware.Auth(queries))
|
||||||
r.Post("/pairing-sessions", h.CreateDaemonPairingSession)
|
|
||||||
r.Get("/pairing-sessions/{token}", h.GetDaemonPairingSession)
|
|
||||||
r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession)
|
|
||||||
|
|
||||||
// Authenticated daemon routes — require daemon token (mdt_) or user JWT/PAT.
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(middleware.DaemonAuth(queries))
|
|
||||||
|
|
||||||
r.Post("/register", h.DaemonRegister)
|
r.Post("/register", h.DaemonRegister)
|
||||||
r.Post("/deregister", h.DaemonDeregister)
|
r.Post("/deregister", h.DaemonDeregister)
|
||||||
|
|
@ -107,15 +104,16 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||||
r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages)
|
r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages)
|
||||||
r.Get("/tasks/{taskId}/messages", h.ListTaskMessages)
|
r.Get("/tasks/{taskId}/messages", h.ListTaskMessages)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
// Protected API routes
|
// Protected API routes
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.Auth(queries))
|
r.Use(middleware.Auth(queries))
|
||||||
|
r.Use(middleware.RefreshCloudFrontCookies(cfSigner))
|
||||||
|
|
||||||
// --- User-scoped routes (no workspace context required) ---
|
// --- User-scoped routes (no workspace context required) ---
|
||||||
r.Get("/api/me", h.GetMe)
|
r.Get("/api/me", h.GetMe)
|
||||||
r.Patch("/api/me", h.UpdateMe)
|
r.Patch("/api/me", h.UpdateMe)
|
||||||
|
r.Post("/api/upload-file", h.UploadFile)
|
||||||
|
|
||||||
r.Route("/api/workspaces", func(r chi.Router) {
|
r.Route("/api/workspaces", func(r chi.Router) {
|
||||||
r.Get("/", h.ListWorkspaces)
|
r.Get("/", h.ListWorkspaces)
|
||||||
|
|
@ -150,8 +148,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||||
r.Delete("/{id}", h.RevokePersonalAccessToken)
|
r.Delete("/{id}", h.RevokePersonalAccessToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession)
|
|
||||||
|
|
||||||
// --- Workspace-scoped routes (all require workspace membership) ---
|
// --- Workspace-scoped routes (all require workspace membership) ---
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireWorkspaceMember(queries))
|
r.Use(middleware.RequireWorkspaceMember(queries))
|
||||||
|
|
@ -176,9 +172,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||||
r.Get("/task-runs", h.ListTasksByIssue)
|
r.Get("/task-runs", h.ListTasksByIssue)
|
||||||
r.Post("/reactions", h.AddIssueReaction)
|
r.Post("/reactions", h.AddIssueReaction)
|
||||||
r.Delete("/reactions", h.RemoveIssueReaction)
|
r.Delete("/reactions", h.RemoveIssueReaction)
|
||||||
|
r.Get("/attachments", h.ListAttachments)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
r.Delete("/api/attachments/{id}", h.DeleteAttachment)
|
||||||
|
|
||||||
// Comments
|
// Comments
|
||||||
r.Route("/api/comments/{commentId}", func(r chi.Router) {
|
r.Route("/api/comments/{commentId}", func(r chi.Router) {
|
||||||
r.Put("/", h.UpdateComment)
|
r.Put("/", h.UpdateComment)
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,41 @@ module github.com/multica-ai/multica/server
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.13
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.13
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.8.0
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
|
github.com/lmittmann/tint v1.1.3
|
||||||
github.com/resend/resend-go/v2 v2.28.0
|
github.com/resend/resend-go/v2 v2.28.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
||||||
|
github.com/aws/smithy-go v1.24.2 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/lmittmann/tint v1.1.3 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,43 @@
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 h1:z2ayoK3pOvf8ODj/vPR0FgAS5ONruBq0F94SRoW/BIU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5/go.mod h1:mpZB5HAl4ZIISod9qCi12xZ170TbHX9CCJV5y7nb7QU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||||
|
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||||
|
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
|
|
||||||
203
server/internal/auth/cloudfront.go
Normal file
203
server/internal/auth/cloudfront.go
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloudFrontSigner generates signed cookies for CloudFront private distributions.
|
||||||
|
type CloudFrontSigner struct {
|
||||||
|
keyPairID string
|
||||||
|
privateKey *rsa.PrivateKey
|
||||||
|
domain string // CDN domain, e.g. "static.multica.ai"
|
||||||
|
cookieDomain string // cookie scope, e.g. ".multica.ai"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCloudFrontSignerFromEnv creates a signer from environment variables.
|
||||||
|
// Returns nil if CLOUDFRONT_KEY_PAIR_ID is not set (disables signed cookies).
|
||||||
|
//
|
||||||
|
// Private key resolution order:
|
||||||
|
// 1. AWS Secrets Manager (CLOUDFRONT_PRIVATE_KEY_SECRET — secret name/ARN)
|
||||||
|
// 2. Environment variable fallback (CLOUDFRONT_PRIVATE_KEY — base64-encoded PEM, for local dev only)
|
||||||
|
//
|
||||||
|
// Other required environment variables:
|
||||||
|
// - CLOUDFRONT_KEY_PAIR_ID
|
||||||
|
// - CLOUDFRONT_DOMAIN (e.g. "static.multica.ai")
|
||||||
|
// - COOKIE_DOMAIN (e.g. ".multica.ai")
|
||||||
|
func NewCloudFrontSignerFromEnv() *CloudFrontSigner {
|
||||||
|
keyPairID := os.Getenv("CLOUDFRONT_KEY_PAIR_ID")
|
||||||
|
if keyPairID == "" {
|
||||||
|
slog.Info("CLOUDFRONT_KEY_PAIR_ID not set, signed cookies disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := os.Getenv("CLOUDFRONT_DOMAIN")
|
||||||
|
if domain == "" {
|
||||||
|
slog.Error("CLOUDFRONT_DOMAIN not set")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieDomain := os.Getenv("COOKIE_DOMAIN")
|
||||||
|
if cookieDomain == "" {
|
||||||
|
slog.Error("COOKIE_DOMAIN not set")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rsaKey, err := loadPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load CloudFront private key", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("CloudFront cookie signer initialized", "key_pair_id", keyPairID, "domain", domain)
|
||||||
|
return &CloudFrontSigner{
|
||||||
|
keyPairID: keyPairID,
|
||||||
|
privateKey: rsaKey,
|
||||||
|
domain: domain,
|
||||||
|
cookieDomain: cookieDomain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPrivateKey loads the RSA private key from Secrets Manager or env var fallback.
|
||||||
|
func loadPrivateKey() (*rsa.PrivateKey, error) {
|
||||||
|
// 1. Try Secrets Manager
|
||||||
|
if secretName := os.Getenv("CLOUDFRONT_PRIVATE_KEY_SECRET"); secretName != "" {
|
||||||
|
slog.Info("loading CloudFront private key from Secrets Manager", "secret", secretName)
|
||||||
|
return loadKeyFromSecretsManager(secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: base64-encoded env var (local dev)
|
||||||
|
if pkB64 := os.Getenv("CLOUDFRONT_PRIVATE_KEY"); pkB64 != "" {
|
||||||
|
slog.Info("loading CloudFront private key from environment variable (local dev)")
|
||||||
|
pemBytes, err := base64.StdEncoding.DecodeString(pkB64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||||
|
}
|
||||||
|
return parseRSAPrivateKey(pemBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("neither CLOUDFRONT_PRIVATE_KEY_SECRET nor CLOUDFRONT_PRIVATE_KEY is set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadKeyFromSecretsManager(secretName string) (*rsa.PrivateKey, error) {
|
||||||
|
cfg, err := awsconfig.LoadDefaultConfig(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load AWS config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := secretsmanager.NewFromConfig(cfg)
|
||||||
|
result, err := client.GetSecretValue(context.Background(), &secretsmanager.GetSecretValueInput{
|
||||||
|
SecretId: aws.String(secretName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get secret %q: %w", secretName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.SecretString == nil {
|
||||||
|
return nil, fmt.Errorf("secret %q has no string value", secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseRSAPrivateKey([]byte(*result.SecretString))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
|
||||||
|
block, _ := pem.Decode(pemBytes)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PKCS8 first, then PKCS1
|
||||||
|
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||||
|
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
|
||||||
|
return rsaKey, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("PKCS8 key is not RSA")
|
||||||
|
}
|
||||||
|
|
||||||
|
rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse private key: %w", err)
|
||||||
|
}
|
||||||
|
return rsaKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedCookies generates the three CloudFront signed cookies with the given expiry.
|
||||||
|
func (s *CloudFrontSigner) SignedCookies(expiry time.Time) []*http.Cookie {
|
||||||
|
policy := fmt.Sprintf(`{"Statement":[{"Resource":"https://%s/*","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, s.domain, expiry.Unix())
|
||||||
|
|
||||||
|
encodedPolicy := cfBase64Encode([]byte(policy))
|
||||||
|
|
||||||
|
h := sha1.New()
|
||||||
|
h.Write([]byte(policy))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, h.Sum(nil))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to sign CloudFront policy", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
encodedSig := cfBase64Encode(sig)
|
||||||
|
|
||||||
|
cookieAttrs := func(name, value string) *http.Cookie {
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Domain: s.cookieDomain,
|
||||||
|
Path: "/",
|
||||||
|
Expires: expiry,
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteNoneMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*http.Cookie{
|
||||||
|
cookieAttrs("CloudFront-Policy", encodedPolicy),
|
||||||
|
cookieAttrs("CloudFront-Signature", encodedSig),
|
||||||
|
cookieAttrs("CloudFront-Key-Pair-Id", s.keyPairID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedURL generates a CloudFront signed URL for the given resource URL.
|
||||||
|
// Used by CLI/API clients that don't have browser cookies.
|
||||||
|
func (s *CloudFrontSigner) SignedURL(rawURL string, expiry time.Time) string {
|
||||||
|
policy := fmt.Sprintf(`{"Statement":[{"Resource":"%s","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, rawURL, expiry.Unix())
|
||||||
|
|
||||||
|
encodedPolicy := cfBase64Encode([]byte(policy))
|
||||||
|
|
||||||
|
h := sha1.New()
|
||||||
|
h.Write([]byte(policy))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, h.Sum(nil))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to sign CloudFront URL", "error", err)
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
encodedSig := cfBase64Encode(sig)
|
||||||
|
|
||||||
|
separator := "?"
|
||||||
|
if strings.Contains(rawURL, "?") {
|
||||||
|
separator = "&"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%sPolicy=%s&Signature=%s&Key-Pair-Id=%s", rawURL, separator, encodedPolicy, encodedSig, s.keyPairID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cfBase64Encode applies CloudFront's URL-safe base64 encoding.
|
||||||
|
func cfBase64Encode(data []byte) string {
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
|
r := strings.NewReplacer("+", "-", "=", "_", "/", "~")
|
||||||
|
return r.Replace(encoded)
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ type TimelineEntry struct {
|
||||||
UpdatedAt *string `json:"updated_at,omitempty"`
|
UpdatedAt *string `json:"updated_at,omitempty"`
|
||||||
CommentType *string `json:"comment_type,omitempty"`
|
CommentType *string `json:"comment_type,omitempty"`
|
||||||
Reactions []ReactionResponse `json:"reactions,omitempty"`
|
Reactions []ReactionResponse `json:"reactions,omitempty"`
|
||||||
|
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTimeline returns a merged, chronologically-sorted timeline of activities
|
// ListTimeline returns a merged, chronologically-sorted timeline of activities
|
||||||
|
|
@ -79,20 +80,22 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch reactions for all comments in one batch.
|
// Fetch reactions and attachments for all comments in one batch.
|
||||||
commentIDs := make([]pgtype.UUID, len(comments))
|
commentIDs := make([]pgtype.UUID, len(comments))
|
||||||
for i, c := range comments {
|
for i, c := range comments {
|
||||||
commentIDs[i] = c.ID
|
commentIDs[i] = c.ID
|
||||||
}
|
}
|
||||||
grouped := h.groupReactions(r, commentIDs)
|
grouped := h.groupReactions(r, commentIDs)
|
||||||
|
groupedAtt := h.groupAttachments(r, commentIDs)
|
||||||
|
|
||||||
for _, c := range comments {
|
for _, c := range comments {
|
||||||
content := c.Content
|
content := c.Content
|
||||||
commentType := c.Type
|
commentType := c.Type
|
||||||
updatedAt := timestampToString(c.UpdatedAt)
|
updatedAt := timestampToString(c.UpdatedAt)
|
||||||
|
cid := uuidToString(c.ID)
|
||||||
timeline = append(timeline, TimelineEntry{
|
timeline = append(timeline, TimelineEntry{
|
||||||
Type: "comment",
|
Type: "comment",
|
||||||
ID: uuidToString(c.ID),
|
ID: cid,
|
||||||
ActorType: c.AuthorType,
|
ActorType: c.AuthorType,
|
||||||
ActorID: uuidToString(c.AuthorID),
|
ActorID: uuidToString(c.AuthorID),
|
||||||
Content: &content,
|
Content: &content,
|
||||||
|
|
@ -100,7 +103,8 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
|
||||||
ParentID: uuidToPtr(c.ParentID),
|
ParentID: uuidToPtr(c.ParentID),
|
||||||
CreatedAt: timestampToString(c.CreatedAt),
|
CreatedAt: timestampToString(c.CreatedAt),
|
||||||
UpdatedAt: &updatedAt,
|
UpdatedAt: &updatedAt,
|
||||||
Reactions: grouped[uuidToString(c.ID)],
|
Reactions: grouped[cid],
|
||||||
|
Attachments: groupedAtt[cid],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -328,24 +328,23 @@ type UpdateAgentRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// canManageAgent checks whether the current user can update or delete an agent.
|
// canManageAgent checks whether the current user can update or delete an agent.
|
||||||
// Workspace-visible agents require owner/admin role. Private agents additionally
|
// Workspace-visible agents can be managed by any workspace member.
|
||||||
// require the user to be the agent's owner (or a workspace owner/admin).
|
// Private agents can only be managed by their owner or workspace owner/admin.
|
||||||
func (h *Handler) canManageAgent(w http.ResponseWriter, r *http.Request, agent db.Agent) bool {
|
func (h *Handler) canManageAgent(w http.ResponseWriter, r *http.Request, agent db.Agent) bool {
|
||||||
wsID := uuidToString(agent.WorkspaceID)
|
wsID := uuidToString(agent.WorkspaceID)
|
||||||
member, ok := h.requireWorkspaceRole(w, r, wsID, "agent not found", "owner", "admin", "member")
|
member, ok := h.requireWorkspaceRole(w, r, wsID, "agent not found", "owner", "admin", "member")
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if agent.Visibility != "private" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
isAdmin := roleAllowed(member.Role, "owner", "admin")
|
isAdmin := roleAllowed(member.Role, "owner", "admin")
|
||||||
isAgentOwner := uuidToString(agent.OwnerID) == requestUserID(r)
|
isAgentOwner := uuidToString(agent.OwnerID) == requestUserID(r)
|
||||||
if agent.Visibility == "private" && !isAdmin && !isAgentOwner {
|
if !isAdmin && !isAgentOwner {
|
||||||
writeError(w, http.StatusForbidden, "only the agent owner can manage this private agent")
|
writeError(w, http.StatusForbidden, "only the agent owner can manage this private agent")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if agent.Visibility != "private" && !isAdmin && !isAgentOwner {
|
|
||||||
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,13 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set CloudFront signed cookies for CDN access.
|
||||||
|
if h.CFSigner != nil {
|
||||||
|
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) {
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("user logged in", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
|
slog.Info("user logged in", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
|
||||||
writeJSON(w, http.StatusOK, LoginResponse{
|
writeJSON(w, http.StatusOK, LoginResponse{
|
||||||
Token: tokenString,
|
Token: tokenString,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -8,6 +9,7 @@ import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/multica-ai/multica/server/internal/logger"
|
"github.com/multica-ai/multica/server/internal/logger"
|
||||||
|
"github.com/multica-ai/multica/server/internal/util"
|
||||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||||
)
|
)
|
||||||
|
|
@ -23,12 +25,16 @@ type CommentResponse struct {
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Reactions []ReactionResponse `json:"reactions"`
|
Reactions []ReactionResponse `json:"reactions"`
|
||||||
|
Attachments []AttachmentResponse `json:"attachments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func commentToResponse(c db.Comment, reactions []ReactionResponse) CommentResponse {
|
func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse {
|
||||||
if reactions == nil {
|
if reactions == nil {
|
||||||
reactions = []ReactionResponse{}
|
reactions = []ReactionResponse{}
|
||||||
}
|
}
|
||||||
|
if attachments == nil {
|
||||||
|
attachments = []AttachmentResponse{}
|
||||||
|
}
|
||||||
return CommentResponse{
|
return CommentResponse{
|
||||||
ID: uuidToString(c.ID),
|
ID: uuidToString(c.ID),
|
||||||
IssueID: uuidToString(c.IssueID),
|
IssueID: uuidToString(c.IssueID),
|
||||||
|
|
@ -40,6 +46,7 @@ func commentToResponse(c db.Comment, reactions []ReactionResponse) CommentRespon
|
||||||
CreatedAt: timestampToString(c.CreatedAt),
|
CreatedAt: timestampToString(c.CreatedAt),
|
||||||
UpdatedAt: timestampToString(c.UpdatedAt),
|
UpdatedAt: timestampToString(c.UpdatedAt),
|
||||||
Reactions: reactions,
|
Reactions: reactions,
|
||||||
|
Attachments: attachments,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,10 +71,12 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
||||||
commentIDs[i] = c.ID
|
commentIDs[i] = c.ID
|
||||||
}
|
}
|
||||||
grouped := h.groupReactions(r, commentIDs)
|
grouped := h.groupReactions(r, commentIDs)
|
||||||
|
groupedAtt := h.groupAttachments(r, commentIDs)
|
||||||
|
|
||||||
resp := make([]CommentResponse, len(comments))
|
resp := make([]CommentResponse, len(comments))
|
||||||
for i, c := range comments {
|
for i, c := range comments {
|
||||||
resp[i] = commentToResponse(c, grouped[uuidToString(c.ID)])
|
cid := uuidToString(c.ID)
|
||||||
|
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
|
@ -133,7 +142,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := commentToResponse(comment, nil)
|
resp := commentToResponse(comment, nil, nil)
|
||||||
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
|
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
|
||||||
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
|
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
|
||||||
"comment": resp,
|
"comment": resp,
|
||||||
|
|
@ -145,7 +154,10 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// If the issue is assigned to an agent with on_comment trigger, enqueue a new task.
|
// If the issue is assigned to an agent with on_comment trigger, enqueue a new task.
|
||||||
// Skip when the comment comes from the assigned agent itself to avoid loops.
|
// Skip when the comment comes from the assigned agent itself to avoid loops.
|
||||||
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) {
|
// Also skip when the comment @mentions others but not the assignee agent —
|
||||||
|
// the user is talking to someone else, not requesting work from the assignee.
|
||||||
|
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
|
||||||
|
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) {
|
||||||
// Resolve thread root: if the comment is a reply, agent should reply
|
// Resolve thread root: if the comment is a reply, agent should reply
|
||||||
// to the thread root (matching frontend behavior where all replies
|
// to the thread root (matching frontend behavior where all replies
|
||||||
// in a thread share the same top-level parent).
|
// in a thread share the same top-level parent).
|
||||||
|
|
@ -158,9 +170,82 @@ 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)
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, resp)
|
writeJSON(w, http.StatusCreated, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commentMentionsOthersButNotAssignee returns true if the comment @mentions
|
||||||
|
// anyone but does NOT @mention the issue's assignee agent. This is used to
|
||||||
|
// suppress the on_comment trigger when the user is directing their comment at
|
||||||
|
// someone else (e.g. sharing results with a colleague, asking another agent).
|
||||||
|
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
|
||||||
|
mentions := util.ParseMentions(content)
|
||||||
|
if len(mentions) == 0 {
|
||||||
|
return false // No mentions — normal on_comment behavior
|
||||||
|
}
|
||||||
|
if !issue.AssigneeID.Valid {
|
||||||
|
return true // No assignee — mentions target others
|
||||||
|
}
|
||||||
|
assigneeID := uuidToString(issue.AssigneeID)
|
||||||
|
for _, m := range mentions {
|
||||||
|
if m.ID == assigneeID {
|
||||||
|
return false // Assignee is mentioned — allow trigger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true // Others mentioned but not assignee — suppress trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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), and agents with
|
||||||
|
// on_mention trigger disabled.
|
||||||
|
func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, authorType, authorID string) {
|
||||||
|
// Don't trigger on terminal statuses.
|
||||||
|
if issue.Status == "done" || issue.Status == "cancelled" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mentions := util.ParseMentions(comment.Content)
|
||||||
|
for _, m := range mentions {
|
||||||
|
if m.Type != "agent" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Prevent self-trigger: skip if the comment author is this agent.
|
||||||
|
if authorType == "agent" && authorID == m.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agentUUID := parseUUID(m.ID)
|
||||||
|
// Prevent duplicate: skip if this agent is the issue's assignee
|
||||||
|
// (already handled by the on_comment trigger above).
|
||||||
|
if issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
|
||||||
|
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if the agent has on_mention trigger enabled.
|
||||||
|
if !h.isAgentMentionTriggerEnabled(ctx, agentUUID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Dedup: skip if this agent already has a pending task for this issue.
|
||||||
|
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
||||||
|
IssueID: issue.ID,
|
||||||
|
AgentID: agentUUID,
|
||||||
|
})
|
||||||
|
if err != nil || hasPending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Resolve thread root for reply threading.
|
||||||
|
replyTo := comment.ID
|
||||||
|
if comment.ParentID.Valid {
|
||||||
|
replyTo = comment.ParentID
|
||||||
|
}
|
||||||
|
if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, agentUUID, replyTo); err != nil {
|
||||||
|
slog.Warn("enqueue mention agent task failed", "issue_id", uuidToString(issue.ID), "agent_id", m.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||||
commentId := chi.URLParam(r, "commentId")
|
commentId := chi.URLParam(r, "commentId")
|
||||||
|
|
||||||
|
|
@ -215,9 +300,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch reactions for the updated comment.
|
// Fetch reactions and attachments for the updated comment.
|
||||||
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
|
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
|
||||||
resp := commentToResponse(comment, grouped[uuidToString(comment.ID)])
|
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
|
||||||
|
cid := uuidToString(comment.ID)
|
||||||
|
resp := commentToResponse(comment, grouped[cid], groupedAtt[cid])
|
||||||
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
|
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
|
||||||
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp})
|
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp})
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,12 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusBadRequest, "at least one runtime is required")
|
writeError(w, http.StatusBadRequest, "at least one runtime is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the caller is a member of the target workspace.
|
||||||
|
if _, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found"); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID))
|
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "workspace not found")
|
writeError(w, http.StatusNotFound, "workspace not found")
|
||||||
|
|
|
||||||
|
|
@ -1,424 +0,0 @@
|
||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
"github.com/multica-ai/multica/server/internal/auth"
|
|
||||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
||||||
)
|
|
||||||
|
|
||||||
const daemonPairingTTL = 10 * time.Minute
|
|
||||||
|
|
||||||
type daemonPairingSessionRecord struct {
|
|
||||||
Token string
|
|
||||||
DaemonID string
|
|
||||||
DeviceName string
|
|
||||||
RuntimeName string
|
|
||||||
RuntimeType string
|
|
||||||
RuntimeVersion string
|
|
||||||
WorkspaceID pgtype.UUID
|
|
||||||
ApprovedBy pgtype.UUID
|
|
||||||
Status string
|
|
||||||
ApprovedAt pgtype.Timestamptz
|
|
||||||
ClaimedAt pgtype.Timestamptz
|
|
||||||
ExpiresAt pgtype.Timestamptz
|
|
||||||
CreatedAt pgtype.Timestamptz
|
|
||||||
UpdatedAt pgtype.Timestamptz
|
|
||||||
}
|
|
||||||
|
|
||||||
type DaemonPairingSessionResponse struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
DaemonID string `json:"daemon_id"`
|
|
||||||
DeviceName string `json:"device_name"`
|
|
||||||
RuntimeName string `json:"runtime_name"`
|
|
||||||
RuntimeType string `json:"runtime_type"`
|
|
||||||
RuntimeVersion string `json:"runtime_version"`
|
|
||||||
WorkspaceID *string `json:"workspace_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
ApprovedAt *string `json:"approved_at"`
|
|
||||||
ClaimedAt *string `json:"claimed_at"`
|
|
||||||
ExpiresAt string `json:"expires_at"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
LinkURL *string `json:"link_url,omitempty"`
|
|
||||||
DaemonToken *string `json:"daemon_token,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateDaemonPairingSessionRequest struct {
|
|
||||||
DaemonID string `json:"daemon_id"`
|
|
||||||
DeviceName string `json:"device_name"`
|
|
||||||
RuntimeName string `json:"runtime_name"`
|
|
||||||
RuntimeType string `json:"runtime_type"`
|
|
||||||
RuntimeVersion string `json:"runtime_version"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApproveDaemonPairingSessionRequest struct {
|
|
||||||
WorkspaceID string `json:"workspace_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func daemonAppBaseURL() string {
|
|
||||||
for _, key := range []string{"MULTICA_APP_URL", "FRONTEND_ORIGIN"} {
|
|
||||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
||||||
return strings.TrimRight(value, "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "http://localhost:3000"
|
|
||||||
}
|
|
||||||
|
|
||||||
func daemonPairingLinkURL(token string) string {
|
|
||||||
base := daemonAppBaseURL()
|
|
||||||
return base + "/pair/local?token=" + url.QueryEscape(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func daemonPairingSessionToResponse(rec daemonPairingSessionRecord, includeLink bool) DaemonPairingSessionResponse {
|
|
||||||
resp := DaemonPairingSessionResponse{
|
|
||||||
Token: rec.Token,
|
|
||||||
DaemonID: rec.DaemonID,
|
|
||||||
DeviceName: rec.DeviceName,
|
|
||||||
RuntimeName: rec.RuntimeName,
|
|
||||||
RuntimeType: rec.RuntimeType,
|
|
||||||
RuntimeVersion: rec.RuntimeVersion,
|
|
||||||
WorkspaceID: uuidToPtr(rec.WorkspaceID),
|
|
||||||
Status: rec.Status,
|
|
||||||
ApprovedAt: timestampToPtr(rec.ApprovedAt),
|
|
||||||
ClaimedAt: timestampToPtr(rec.ClaimedAt),
|
|
||||||
ExpiresAt: timestampToString(rec.ExpiresAt),
|
|
||||||
CreatedAt: timestampToString(rec.CreatedAt),
|
|
||||||
UpdatedAt: timestampToString(rec.UpdatedAt),
|
|
||||||
}
|
|
||||||
if includeLink {
|
|
||||||
link := daemonPairingLinkURL(rec.Token)
|
|
||||||
resp.LinkURL = &link
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomDaemonPairingToken() (string, error) {
|
|
||||||
bytes := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) getDaemonPairingSession(ctx context.Context, token string) (daemonPairingSessionRecord, error) {
|
|
||||||
if h.DB == nil {
|
|
||||||
return daemonPairingSessionRecord{}, fmt.Errorf("database executor is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
var rec daemonPairingSessionRecord
|
|
||||||
err := h.DB.QueryRow(ctx, `
|
|
||||||
SELECT
|
|
||||||
token,
|
|
||||||
daemon_id,
|
|
||||||
device_name,
|
|
||||||
runtime_name,
|
|
||||||
runtime_type,
|
|
||||||
runtime_version,
|
|
||||||
workspace_id,
|
|
||||||
approved_by,
|
|
||||||
status,
|
|
||||||
approved_at,
|
|
||||||
claimed_at,
|
|
||||||
expires_at,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
FROM daemon_pairing_session
|
|
||||||
WHERE token = $1
|
|
||||||
`, token).Scan(
|
|
||||||
&rec.Token,
|
|
||||||
&rec.DaemonID,
|
|
||||||
&rec.DeviceName,
|
|
||||||
&rec.RuntimeName,
|
|
||||||
&rec.RuntimeType,
|
|
||||||
&rec.RuntimeVersion,
|
|
||||||
&rec.WorkspaceID,
|
|
||||||
&rec.ApprovedBy,
|
|
||||||
&rec.Status,
|
|
||||||
&rec.ApprovedAt,
|
|
||||||
&rec.ClaimedAt,
|
|
||||||
&rec.ExpiresAt,
|
|
||||||
&rec.CreatedAt,
|
|
||||||
&rec.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return daemonPairingSessionRecord{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if rec.Status == "pending" && rec.ExpiresAt.Valid && rec.ExpiresAt.Time.Before(time.Now()) {
|
|
||||||
if _, err := h.DB.Exec(ctx, `
|
|
||||||
UPDATE daemon_pairing_session
|
|
||||||
SET status = 'expired', updated_at = now()
|
|
||||||
WHERE token = $1 AND status = 'pending'
|
|
||||||
`, token); err == nil {
|
|
||||||
rec.Status = "expired"
|
|
||||||
rec.UpdatedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rec, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) CreateDaemonPairingSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if h.DB == nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database executor is not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CreateDaemonPairingSessionRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req.DaemonID = strings.TrimSpace(req.DaemonID)
|
|
||||||
req.DeviceName = strings.TrimSpace(req.DeviceName)
|
|
||||||
req.RuntimeName = strings.TrimSpace(req.RuntimeName)
|
|
||||||
req.RuntimeType = strings.TrimSpace(req.RuntimeType)
|
|
||||||
req.RuntimeVersion = strings.TrimSpace(req.RuntimeVersion)
|
|
||||||
|
|
||||||
if req.DaemonID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "daemon_id is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.DeviceName == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "device_name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.RuntimeName == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "runtime_name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.RuntimeType == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "runtime_type is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := randomDaemonPairingToken()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create pairing token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expiresAt := time.Now().Add(daemonPairingTTL)
|
|
||||||
var rec daemonPairingSessionRecord
|
|
||||||
err = h.DB.QueryRow(r.Context(), `
|
|
||||||
INSERT INTO daemon_pairing_session (
|
|
||||||
token,
|
|
||||||
daemon_id,
|
|
||||||
device_name,
|
|
||||||
runtime_name,
|
|
||||||
runtime_type,
|
|
||||||
runtime_version,
|
|
||||||
expires_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING
|
|
||||||
token,
|
|
||||||
daemon_id,
|
|
||||||
device_name,
|
|
||||||
runtime_name,
|
|
||||||
runtime_type,
|
|
||||||
runtime_version,
|
|
||||||
workspace_id,
|
|
||||||
approved_by,
|
|
||||||
status,
|
|
||||||
approved_at,
|
|
||||||
claimed_at,
|
|
||||||
expires_at,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
`,
|
|
||||||
token,
|
|
||||||
req.DaemonID,
|
|
||||||
req.DeviceName,
|
|
||||||
req.RuntimeName,
|
|
||||||
req.RuntimeType,
|
|
||||||
req.RuntimeVersion,
|
|
||||||
expiresAt,
|
|
||||||
).Scan(
|
|
||||||
&rec.Token,
|
|
||||||
&rec.DaemonID,
|
|
||||||
&rec.DeviceName,
|
|
||||||
&rec.RuntimeName,
|
|
||||||
&rec.RuntimeType,
|
|
||||||
&rec.RuntimeVersion,
|
|
||||||
&rec.WorkspaceID,
|
|
||||||
&rec.ApprovedBy,
|
|
||||||
&rec.Status,
|
|
||||||
&rec.ApprovedAt,
|
|
||||||
&rec.ClaimedAt,
|
|
||||||
&rec.ExpiresAt,
|
|
||||||
&rec.CreatedAt,
|
|
||||||
&rec.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create pairing session")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, daemonPairingSessionToResponse(rec, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GetDaemonPairingSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token := chi.URLParam(r, "token")
|
|
||||||
rec, err := h.getDaemonPairingSession(r.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "pairing session not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) ApproveDaemonPairingSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token := chi.URLParam(r, "token")
|
|
||||||
rec, err := h.getDaemonPairingSession(r.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "pairing session not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if rec.Status == "expired" {
|
|
||||||
writeError(w, http.StatusBadRequest, "pairing session expired")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if rec.Status == "claimed" {
|
|
||||||
writeError(w, http.StatusBadRequest, "pairing session already claimed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if rec.Status == "approved" {
|
|
||||||
writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req ApproveDaemonPairingSessionRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.WorkspaceID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, ok := requireUserID(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found"); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.DB == nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database executor is not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := h.DB.Exec(r.Context(), `
|
|
||||||
UPDATE daemon_pairing_session
|
|
||||||
SET
|
|
||||||
workspace_id = $2,
|
|
||||||
approved_by = $3,
|
|
||||||
status = 'approved',
|
|
||||||
approved_at = now(),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE token = $1 AND status = 'pending'
|
|
||||||
`, token, parseUUID(req.WorkspaceID), parseUUID(userID)); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to approve pairing session")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, err = h.getDaemonPairingSession(r.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to reload pairing session")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) ClaimDaemonPairingSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token := chi.URLParam(r, "token")
|
|
||||||
rec, err := h.getDaemonPairingSession(r.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "pairing session not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if rec.Status == "claimed" {
|
|
||||||
writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if rec.Status != "approved" {
|
|
||||||
writeError(w, http.StatusBadRequest, "pairing session is not approved")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.DB == nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "database executor is not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := h.DB.Exec(r.Context(), `
|
|
||||||
UPDATE daemon_pairing_session
|
|
||||||
SET
|
|
||||||
status = 'claimed',
|
|
||||||
claimed_at = now(),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE token = $1 AND status = 'approved'
|
|
||||||
`, token); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to claim pairing session")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, err = h.getDaemonPairingSession(r.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to reload pairing session")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := daemonPairingSessionToResponse(rec, true)
|
|
||||||
|
|
||||||
// Issue a daemon auth token bound to the workspace and daemon.
|
|
||||||
if rec.WorkspaceID.Valid {
|
|
||||||
plainToken, err := auth.GenerateDaemonToken()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to generate daemon token", "error", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate daemon token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash := auth.HashToken(plainToken)
|
|
||||||
|
|
||||||
// Revoke any existing tokens for this workspace+daemon pair.
|
|
||||||
_ = h.Queries.DeleteDaemonTokensByWorkspaceAndDaemon(r.Context(), db.DeleteDaemonTokensByWorkspaceAndDaemonParams{
|
|
||||||
WorkspaceID: rec.WorkspaceID,
|
|
||||||
DaemonID: rec.DaemonID,
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = h.Queries.CreateDaemonToken(r.Context(), db.CreateDaemonTokenParams{
|
|
||||||
TokenHash: hash,
|
|
||||||
WorkspaceID: rec.WorkspaceID,
|
|
||||||
DaemonID: rec.DaemonID,
|
|
||||||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(365 * 24 * time.Hour), Valid: true},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to store daemon token", "error", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to store daemon token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp.DaemonToken = &plainToken
|
|
||||||
slog.Info("daemon token issued", "daemon_id", rec.DaemonID, "workspace_id", uuidToPtr(rec.WorkspaceID))
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
296
server/internal/handler/file.go
Normal file
296
server/internal/handler/file.go
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxUploadSize = 10 << 20 // 10 MB
|
||||||
|
|
||||||
|
// Allowed MIME type prefixes and exact types for uploads.
|
||||||
|
var allowedContentTypes = map[string]bool{
|
||||||
|
"image/png": true,
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/gif": true,
|
||||||
|
"image/webp": true,
|
||||||
|
"image/svg+xml": true,
|
||||||
|
"application/pdf": true,
|
||||||
|
"text/plain": true,
|
||||||
|
"text/csv": true,
|
||||||
|
"application/json": true,
|
||||||
|
"video/mp4": true,
|
||||||
|
"video/webm": true,
|
||||||
|
"audio/mpeg": true,
|
||||||
|
"audio/wav": true,
|
||||||
|
"application/zip": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isContentTypeAllowed(ct string) bool {
|
||||||
|
// Normalize: take only the media type, strip parameters like charset.
|
||||||
|
ct = strings.TrimSpace(strings.SplitN(ct, ";", 2)[0])
|
||||||
|
ct = strings.ToLower(ct)
|
||||||
|
return allowedContentTypes[ct]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type AttachmentResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WorkspaceID string `json:"workspace_id"`
|
||||||
|
IssueID *string `json:"issue_id"`
|
||||||
|
CommentID *string `json:"comment_id"`
|
||||||
|
UploaderType string `json:"uploader_type"`
|
||||||
|
UploaderID string `json:"uploader_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse {
|
||||||
|
resp := AttachmentResponse{
|
||||||
|
ID: uuidToString(a.ID),
|
||||||
|
WorkspaceID: uuidToString(a.WorkspaceID),
|
||||||
|
UploaderType: a.UploaderType,
|
||||||
|
UploaderID: uuidToString(a.UploaderID),
|
||||||
|
Filename: a.Filename,
|
||||||
|
URL: a.Url,
|
||||||
|
DownloadURL: a.Url,
|
||||||
|
ContentType: a.ContentType,
|
||||||
|
SizeBytes: a.SizeBytes,
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
if a.IssueID.Valid {
|
||||||
|
s := uuidToString(a.IssueID)
|
||||||
|
resp.IssueID = &s
|
||||||
|
}
|
||||||
|
if a.CommentID.Valid {
|
||||||
|
s := uuidToString(a.CommentID)
|
||||||
|
resp.CommentID = &s
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupAttachments loads attachments for multiple comments and groups them by comment ID.
|
||||||
|
func (h *Handler) groupAttachments(r *http.Request, commentIDs []pgtype.UUID) map[string][]AttachmentResponse {
|
||||||
|
if len(commentIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
attachments, err := h.Queries.ListAttachmentsByCommentIDs(r.Context(), commentIDs)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load attachments for comments", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
grouped := make(map[string][]AttachmentResponse, len(commentIDs))
|
||||||
|
for _, a := range attachments {
|
||||||
|
cid := uuidToString(a.CommentID)
|
||||||
|
grouped[cid] = append(grouped[cid], h.attachmentToResponse(a))
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UploadFile — POST /api/upload-file
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.Storage == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "file upload not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := requireUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceID := resolveWorkspaceID(r)
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "file too large or invalid multipart form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing file field: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Sniff actual content type from file bytes instead of trusting the client header.
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, err := file.Read(buf)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
writeError(w, http.StatusBadRequest, "failed to read file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := http.DetectContentType(buf[:n])
|
||||||
|
if !isContentTypeAllowed(contentType) {
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Seek back so the full file is uploaded.
|
||||||
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to read file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "failed to read file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
slog.Error("failed to generate file key", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := hex.EncodeToString(b) + path.Ext(header.Filename)
|
||||||
|
|
||||||
|
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("file upload failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "upload failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If workspace context is available, create an attachment record.
|
||||||
|
if workspaceID != "" {
|
||||||
|
uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID)
|
||||||
|
|
||||||
|
params := db.CreateAttachmentParams{
|
||||||
|
WorkspaceID: parseUUID(workspaceID),
|
||||||
|
UploaderType: uploaderType,
|
||||||
|
UploaderID: parseUUID(uploaderID),
|
||||||
|
Filename: header.Filename,
|
||||||
|
Url: link,
|
||||||
|
ContentType: contentType,
|
||||||
|
SizeBytes: int64(len(data)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional issue_id / comment_id from form fields
|
||||||
|
if issueID := r.FormValue("issue_id"); issueID != "" {
|
||||||
|
params.IssueID = parseUUID(issueID)
|
||||||
|
}
|
||||||
|
if commentID := r.FormValue("comment_id"); commentID != "" {
|
||||||
|
params.CommentID = parseUUID(commentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
att, err := h.Queries.CreateAttachment(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create attachment record", "error", err)
|
||||||
|
// S3 upload succeeded but DB record failed — still return the link
|
||||||
|
// so the file is usable. Log the error for investigation.
|
||||||
|
} else {
|
||||||
|
writeJSON(w, http.StatusOK, h.attachmentToResponse(att))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback response (no workspace context, e.g. avatar upload)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"filename": header.Filename,
|
||||||
|
"link": link,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListAttachments — GET /api/issues/{id}/attachments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
issueID := chi.URLParam(r, "id")
|
||||||
|
issue, ok := h.loadIssueForUser(w, r, issueID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
|
||||||
|
IssueID: issue.ID,
|
||||||
|
WorkspaceID: issue.WorkspaceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to list attachments", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list attachments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]AttachmentResponse, len(attachments))
|
||||||
|
for i, a := range attachments {
|
||||||
|
resp[i] = h.attachmentToResponse(a)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeleteAttachment — DELETE /api/attachments/{id}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *Handler) DeleteAttachment(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
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := requireUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the uploader (or workspace admin) can delete
|
||||||
|
uploaderID := uuidToString(att.UploaderID)
|
||||||
|
isUploader := att.UploaderType == "member" && uploaderID == userID
|
||||||
|
member, hasMember := ctxMember(r.Context())
|
||||||
|
isAdmin := hasMember && (member.Role == "admin" || member.Role == "owner")
|
||||||
|
|
||||||
|
if !isUploader && !isAdmin {
|
||||||
|
writeError(w, http.StatusForbidden, "not authorized to delete this attachment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.Queries.DeleteAttachment(r.Context(), db.DeleteAttachmentParams{
|
||||||
|
ID: att.ID,
|
||||||
|
WorkspaceID: att.WorkspaceID,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("failed to delete attachment", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete attachment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
@ -12,10 +12,12 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
|
"github.com/multica-ai/multica/server/internal/auth"
|
||||||
"github.com/multica-ai/multica/server/internal/events"
|
"github.com/multica-ai/multica/server/internal/events"
|
||||||
"github.com/multica-ai/multica/server/internal/middleware"
|
"github.com/multica-ai/multica/server/internal/middleware"
|
||||||
"github.com/multica-ai/multica/server/internal/realtime"
|
"github.com/multica-ai/multica/server/internal/realtime"
|
||||||
"github.com/multica-ai/multica/server/internal/service"
|
"github.com/multica-ai/multica/server/internal/service"
|
||||||
|
"github.com/multica-ai/multica/server/internal/storage"
|
||||||
"github.com/multica-ai/multica/server/internal/util"
|
"github.com/multica-ai/multica/server/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -38,9 +40,11 @@ type Handler struct {
|
||||||
TaskService *service.TaskService
|
TaskService *service.TaskService
|
||||||
EmailService *service.EmailService
|
EmailService *service.EmailService
|
||||||
PingStore *PingStore
|
PingStore *PingStore
|
||||||
|
Storage *storage.S3Storage
|
||||||
|
CFSigner *auth.CloudFrontSigner
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService) *Handler {
|
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, s3 *storage.S3Storage, cfSigner *auth.CloudFrontSigner) *Handler {
|
||||||
var executor dbExecutor
|
var executor dbExecutor
|
||||||
if candidate, ok := txStarter.(dbExecutor); ok {
|
if candidate, ok := txStarter.(dbExecutor); ok {
|
||||||
executor = candidate
|
executor = candidate
|
||||||
|
|
@ -55,6 +59,8 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
|
||||||
TaskService: service.NewTaskService(queries, hub, bus),
|
TaskService: service.NewTaskService(queries, hub, bus),
|
||||||
EmailService: emailService,
|
EmailService: emailService,
|
||||||
PingStore: NewPingStore(),
|
PingStore: NewPingStore(),
|
||||||
|
Storage: s3,
|
||||||
|
CFSigner: cfSigner,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ func TestMain(m *testing.M) {
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
bus := events.New()
|
bus := events.New()
|
||||||
emailSvc := service.NewEmailService()
|
emailSvc := service.NewEmailService()
|
||||||
testHandler = New(queries, pool, hub, bus, emailSvc)
|
testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil)
|
||||||
testPool = pool
|
testPool = pool
|
||||||
|
|
||||||
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
|
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
|
||||||
|
|
@ -729,6 +729,7 @@ func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
|
||||||
"runtimes":[{"name":"Local Codex","type":"codex","version":"1.0.0","status":"online"}]
|
"runtimes":[{"name":"Local Codex","type":"codex","version":"1.0.0","status":"online"}]
|
||||||
}`))
|
}`))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-User-ID", testUserID)
|
||||||
|
|
||||||
testHandler.DaemonRegister(w, req)
|
testHandler.DaemonRegister(w, req)
|
||||||
if w.Code != http.StatusNotFound {
|
if w.Code != http.StatusNotFound {
|
||||||
|
|
|
||||||
|
|
@ -515,6 +515,30 @@ func (h *Handler) isAgentTriggerEnabled(ctx context.Context, issue db.Issue, tri
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isAgentMentionTriggerEnabled checks if a specific agent has the on_mention
|
||||||
|
// trigger enabled. Unlike isAgentTriggerEnabled, this takes an explicit agent
|
||||||
|
// ID rather than deriving it from the issue assignee.
|
||||||
|
func (h *Handler) isAgentMentionTriggerEnabled(ctx context.Context, agentID pgtype.UUID) bool {
|
||||||
|
agent, err := h.Queries.GetAgent(ctx, agentID)
|
||||||
|
if err != nil || !agent.RuntimeID.Valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if agent.Triggers == nil || len(agent.Triggers) == 0 {
|
||||||
|
return true // No config = all triggers enabled by default
|
||||||
|
}
|
||||||
|
|
||||||
|
var triggers []agentTriggerSnapshot
|
||||||
|
if err := json.Unmarshal(agent.Triggers, &triggers); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, trigger := range triggers {
|
||||||
|
if trigger.Type == "on_mention" {
|
||||||
|
return trigger.Enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true // on_mention not configured = enabled by default
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
issue, ok := h.loadIssueForUser(w, r, id)
|
issue, ok := h.loadIssueForUser(w, r, id)
|
||||||
|
|
|
||||||
28
server/internal/middleware/cloudfront.go
Normal file
28
server/internal/middleware/cloudfront.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/multica-ai/multica/server/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RefreshCloudFrontCookies is middleware that refreshes CloudFront signed cookies
|
||||||
|
// on authenticated requests when the cookie is missing (expired or first request
|
||||||
|
// after login). This prevents 403s from the CDN when cookies expire before the
|
||||||
|
// user's session does.
|
||||||
|
func RefreshCloudFrontCookies(signer *auth.CloudFrontSigner) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
if signer == nil {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := r.Cookie("CloudFront-Policy"); err != nil {
|
||||||
|
for _, cookie := range signer.SignedCookies(time.Now().Add(72 * time.Hour)) {
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,36 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t
|
||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnqueueTaskForMention creates a queued task for a mentioned agent on an issue.
|
||||||
|
// Unlike EnqueueTaskForIssue, this takes an explicit agent ID rather than
|
||||||
|
// deriving it from the issue assignee.
|
||||||
|
func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||||
|
agent, err := s.Queries.GetAgent(ctx, agentID)
|
||||||
|
if err != nil {
|
||||||
|
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.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")
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := s.Queries.CreateAgentTask(ctx, db.CreateAgentTaskParams{
|
||||||
|
AgentID: agentID,
|
||||||
|
RuntimeID: agent.RuntimeID,
|
||||||
|
IssueID: issue.ID,
|
||||||
|
Priority: priorityToInt(issue.Priority),
|
||||||
|
TriggerCommentID: triggerCommentID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("mention task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err)
|
||||||
|
return db.AgentTaskQueue{}, fmt.Errorf("create task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("mention task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID))
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CancelTasksForIssue cancels all active tasks for an issue.
|
// CancelTasksForIssue cancels all active tasks for an issue.
|
||||||
func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UUID) error {
|
func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UUID) error {
|
||||||
return s.Queries.CancelAgentTasksByIssue(ctx, issueID)
|
return s.Queries.CancelAgentTasksByIssue(ctx, issueID)
|
||||||
|
|
|
||||||
107
server/internal/storage/s3.go
Normal file
107
server/internal/storage/s3.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type S3Storage struct {
|
||||||
|
client *s3.Client
|
||||||
|
bucket string
|
||||||
|
cdnDomain string // if set, returned URLs use this instead of bucket name
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewS3StorageFromEnv creates an S3Storage from environment variables.
|
||||||
|
// Returns nil if S3_BUCKET is not set.
|
||||||
|
//
|
||||||
|
// Environment variables:
|
||||||
|
// - S3_BUCKET (required)
|
||||||
|
// - S3_REGION (default: us-west-2)
|
||||||
|
// - AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (optional; falls back to default credential chain)
|
||||||
|
func NewS3StorageFromEnv() *S3Storage {
|
||||||
|
bucket := os.Getenv("S3_BUCKET")
|
||||||
|
if bucket == "" {
|
||||||
|
slog.Info("S3_BUCKET not set, file upload disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
region := os.Getenv("S3_REGION")
|
||||||
|
if region == "" {
|
||||||
|
region = "us-west-2"
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []func(*config.LoadOptions) error{
|
||||||
|
config.WithRegion(region),
|
||||||
|
}
|
||||||
|
|
||||||
|
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
|
||||||
|
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
|
||||||
|
if accessKey != "" && secretKey != "" {
|
||||||
|
opts = append(opts, config.WithCredentialsProvider(
|
||||||
|
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadDefaultConfig(context.Background(), opts...)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load AWS config", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cdnDomain := os.Getenv("CLOUDFRONT_DOMAIN")
|
||||||
|
|
||||||
|
slog.Info("S3 storage initialized", "bucket", bucket, "region", region, "cdn_domain", cdnDomain)
|
||||||
|
return &S3Storage{
|
||||||
|
client: s3.NewFromConfig(cfg),
|
||||||
|
bucket: bucket,
|
||||||
|
cdnDomain: cdnDomain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(name))
|
||||||
|
for _, r := range name {
|
||||||
|
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
|
||||||
|
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
|
||||||
|
b.WriteRune('_')
|
||||||
|
} else {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
|
||||||
|
safe := sanitizeFilename(filename)
|
||||||
|
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
Body: bytes.NewReader(data),
|
||||||
|
ContentType: aws.String(contentType),
|
||||||
|
ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, safe)),
|
||||||
|
CacheControl: aws.String("max-age=432000,public"),
|
||||||
|
StorageClass: types.StorageClassIntelligentTiering,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("s3 PutObject: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := s.bucket
|
||||||
|
if s.cdnDomain != "" {
|
||||||
|
domain = s.cdnDomain
|
||||||
|
}
|
||||||
|
link := fmt.Sprintf("https://%s/%s", domain, key)
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
28
server/internal/util/mention.go
Normal file
28
server/internal/util/mention.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package util
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
// Mention represents a parsed @mention from markdown content.
|
||||||
|
type Mention struct {
|
||||||
|
Type string // "member" or "agent"
|
||||||
|
ID string // user_id or agent_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// MentionRe matches [@Label](mention://type/id) in markdown.
|
||||||
|
var MentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`)
|
||||||
|
|
||||||
|
// ParseMentions extracts deduplicated mentions from markdown content.
|
||||||
|
func ParseMentions(content string) []Mention {
|
||||||
|
matches := MentionRe.FindAllStringSubmatch(content, -1)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var result []Mention
|
||||||
|
for _, m := range matches {
|
||||||
|
key := m[1] + ":" + m[2]
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
result = append(result, Mention{Type: m[1], ID: m[2]})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
1
server/migrations/029_attachment.down.sql
Normal file
1
server/migrations/029_attachment.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS attachment;
|
||||||
17
server/migrations/029_attachment.up.sql
Normal file
17
server/migrations/029_attachment.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
CREATE TABLE attachment (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||||
|
issue_id UUID REFERENCES issue(id) ON DELETE CASCADE,
|
||||||
|
comment_id UUID REFERENCES comment(id) ON DELETE CASCADE,
|
||||||
|
uploader_type TEXT NOT NULL CHECK (uploader_type IN ('member', 'agent')),
|
||||||
|
uploader_id UUID NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_attachment_issue ON attachment(issue_id) WHERE issue_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_attachment_comment ON attachment(comment_id) WHERE comment_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_attachment_workspace ON attachment(workspace_id);
|
||||||
21
server/migrations/029_drop_daemon_pairing.down.sql
Normal file
21
server/migrations/029_drop_daemon_pairing.down.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- Re-create the daemon_pairing_session table (from migration 005).
|
||||||
|
CREATE TABLE IF NOT EXISTS daemon_pairing_session (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
daemon_id TEXT NOT NULL,
|
||||||
|
device_name TEXT NOT NULL DEFAULT '',
|
||||||
|
runtime_name TEXT NOT NULL DEFAULT '',
|
||||||
|
runtime_type TEXT NOT NULL DEFAULT '',
|
||||||
|
runtime_version TEXT NOT NULL DEFAULT '',
|
||||||
|
workspace_id UUID REFERENCES workspace(id),
|
||||||
|
approved_by UUID REFERENCES "user"(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
claimed_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daemon_pairing_session_token ON daemon_pairing_session(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daemon_pairing_session_status ON daemon_pairing_session(status, expires_at);
|
||||||
1
server/migrations/029_drop_daemon_pairing.up.sql
Normal file
1
server/migrations/029_drop_daemon_pairing.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS daemon_pairing_session;
|
||||||
1
server/migrations/030_agent_default_private.down.sql
Normal file
1
server/migrations/030_agent_default_private.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'workspace';
|
||||||
1
server/migrations/030_agent_default_private.up.sql
Normal file
1
server/migrations/030_agent_default_private.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'private';
|
||||||
|
|
@ -458,6 +458,25 @@ func (q *Queries) HasPendingTaskForIssue(ctx context.Context, issueID pgtype.UUI
|
||||||
return has_pending, err
|
return has_pending, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPendingTaskForIssueAndAgent = `-- name: HasPendingTaskForIssueAndAgent :one
|
||||||
|
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
|
||||||
|
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched')
|
||||||
|
`
|
||||||
|
|
||||||
|
type HasPendingTaskForIssueAndAgentParams struct {
|
||||||
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
|
AgentID pgtype.UUID `json:"agent_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if a specific agent already has a queued or dispatched task
|
||||||
|
// for the given issue. Used by @mention trigger dedup.
|
||||||
|
func (q *Queries) HasPendingTaskForIssueAndAgent(ctx context.Context, arg HasPendingTaskForIssueAndAgentParams) (bool, error) {
|
||||||
|
row := q.db.QueryRow(ctx, hasPendingTaskForIssueAndAgent, arg.IssueID, arg.AgentID)
|
||||||
|
var has_pending bool
|
||||||
|
err := row.Scan(&has_pending)
|
||||||
|
return has_pending, err
|
||||||
|
}
|
||||||
|
|
||||||
const listActiveTasksByIssue = `-- name: ListActiveTasksByIssue :many
|
const listActiveTasksByIssue = `-- name: ListActiveTasksByIssue :many
|
||||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id FROM agent_task_queue
|
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id FROM agent_task_queue
|
||||||
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
|
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
|
||||||
|
|
|
||||||
226
server/pkg/db/generated/attachment.sql.go
Normal file
226
server/pkg/db/generated/attachment.sql.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: attachment.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createAttachment = `-- name: CreateAttachment :one
|
||||||
|
INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
|
||||||
|
VALUES ($1, $8, $9, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateAttachmentParams struct {
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
UploaderType string `json:"uploader_type"`
|
||||||
|
UploaderID pgtype.UUID `json:"uploader_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
|
CommentID pgtype.UUID `json:"comment_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createAttachment,
|
||||||
|
arg.WorkspaceID,
|
||||||
|
arg.UploaderType,
|
||||||
|
arg.UploaderID,
|
||||||
|
arg.Filename,
|
||||||
|
arg.Url,
|
||||||
|
arg.ContentType,
|
||||||
|
arg.SizeBytes,
|
||||||
|
arg.IssueID,
|
||||||
|
arg.CommentID,
|
||||||
|
)
|
||||||
|
var i Attachment
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.IssueID,
|
||||||
|
&i.CommentID,
|
||||||
|
&i.UploaderType,
|
||||||
|
&i.UploaderID,
|
||||||
|
&i.Filename,
|
||||||
|
&i.Url,
|
||||||
|
&i.ContentType,
|
||||||
|
&i.SizeBytes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAttachment = `-- name: DeleteAttachment :exec
|
||||||
|
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteAttachmentParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteAttachment, arg.ID, arg.WorkspaceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAttachment = `-- name: GetAttachment :one
|
||||||
|
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment
|
||||||
|
WHERE id = $1 AND workspace_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAttachmentParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAttachment(ctx context.Context, arg GetAttachmentParams) (Attachment, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getAttachment, arg.ID, arg.WorkspaceID)
|
||||||
|
var i Attachment
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.IssueID,
|
||||||
|
&i.CommentID,
|
||||||
|
&i.UploaderType,
|
||||||
|
&i.UploaderID,
|
||||||
|
&i.Filename,
|
||||||
|
&i.Url,
|
||||||
|
&i.ContentType,
|
||||||
|
&i.SizeBytes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAttachmentsByComment = `-- name: ListAttachmentsByComment :many
|
||||||
|
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment
|
||||||
|
WHERE comment_id = $1 AND workspace_id = $2
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAttachmentsByCommentParams struct {
|
||||||
|
CommentID pgtype.UUID `json:"comment_id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListAttachmentsByComment(ctx context.Context, arg ListAttachmentsByCommentParams) ([]Attachment, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAttachmentsByComment, arg.CommentID, arg.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Attachment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Attachment
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.IssueID,
|
||||||
|
&i.CommentID,
|
||||||
|
&i.UploaderType,
|
||||||
|
&i.UploaderID,
|
||||||
|
&i.Filename,
|
||||||
|
&i.Url,
|
||||||
|
&i.ContentType,
|
||||||
|
&i.SizeBytes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAttachmentsByCommentIDs = `-- name: ListAttachmentsByCommentIDs :many
|
||||||
|
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment
|
||||||
|
WHERE comment_id = ANY($1::uuid[])
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListAttachmentsByCommentIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Attachment, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAttachmentsByCommentIDs, dollar_1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Attachment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Attachment
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.IssueID,
|
||||||
|
&i.CommentID,
|
||||||
|
&i.UploaderType,
|
||||||
|
&i.UploaderID,
|
||||||
|
&i.Filename,
|
||||||
|
&i.Url,
|
||||||
|
&i.ContentType,
|
||||||
|
&i.SizeBytes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAttachmentsByIssue = `-- name: ListAttachmentsByIssue :many
|
||||||
|
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment
|
||||||
|
WHERE issue_id = $1 AND workspace_id = $2
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAttachmentsByIssueParams struct {
|
||||||
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListAttachmentsByIssue(ctx context.Context, arg ListAttachmentsByIssueParams) ([]Attachment, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAttachmentsByIssue, arg.IssueID, arg.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Attachment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Attachment
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.IssueID,
|
||||||
|
&i.CommentID,
|
||||||
|
&i.UploaderType,
|
||||||
|
&i.UploaderID,
|
||||||
|
&i.Filename,
|
||||||
|
&i.Url,
|
||||||
|
&i.ContentType,
|
||||||
|
&i.SizeBytes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,20 @@ type AgentTaskQueue struct {
|
||||||
TriggerCommentID pgtype.UUID `json:"trigger_comment_id"`
|
TriggerCommentID pgtype.UUID `json:"trigger_comment_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
|
CommentID pgtype.UUID `json:"comment_id"`
|
||||||
|
UploaderType string `json:"uploader_type"`
|
||||||
|
UploaderID pgtype.UUID `json:"uploader_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
IssueID pgtype.UUID `json:"issue_id"`
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,12 @@ WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
||||||
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
|
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
|
||||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched');
|
WHERE issue_id = $1 AND status IN ('queued', 'dispatched');
|
||||||
|
|
||||||
|
-- name: HasPendingTaskForIssueAndAgent :one
|
||||||
|
-- Returns true if a specific agent already has a queued or dispatched task
|
||||||
|
-- for the given issue. Used by @mention trigger dedup.
|
||||||
|
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
|
||||||
|
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched');
|
||||||
|
|
||||||
-- name: ListPendingTasksByRuntime :many
|
-- name: ListPendingTasksByRuntime :many
|
||||||
SELECT * FROM agent_task_queue
|
SELECT * FROM agent_task_queue
|
||||||
WHERE runtime_id = $1 AND status IN ('queued', 'dispatched')
|
WHERE runtime_id = $1 AND status IN ('queued', 'dispatched')
|
||||||
|
|
|
||||||
26
server/pkg/db/queries/attachment.sql
Normal file
26
server/pkg/db/queries/attachment.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- name: CreateAttachment :one
|
||||||
|
INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
|
||||||
|
VALUES ($1, sqlc.narg(issue_id), sqlc.narg(comment_id), $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListAttachmentsByIssue :many
|
||||||
|
SELECT * FROM attachment
|
||||||
|
WHERE issue_id = $1 AND workspace_id = $2
|
||||||
|
ORDER BY created_at ASC;
|
||||||
|
|
||||||
|
-- name: ListAttachmentsByComment :many
|
||||||
|
SELECT * FROM attachment
|
||||||
|
WHERE comment_id = $1 AND workspace_id = $2
|
||||||
|
ORDER BY created_at ASC;
|
||||||
|
|
||||||
|
-- name: GetAttachment :one
|
||||||
|
SELECT * FROM attachment
|
||||||
|
WHERE id = $1 AND workspace_id = $2;
|
||||||
|
|
||||||
|
-- name: ListAttachmentsByCommentIDs :many
|
||||||
|
SELECT * FROM attachment
|
||||||
|
WHERE comment_id = ANY($1::uuid[])
|
||||||
|
ORDER BY created_at ASC;
|
||||||
|
|
||||||
|
-- name: DeleteAttachment :exec
|
||||||
|
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue