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:
Jiang Bohan 2026-03-31 16:25:20 +08:00
commit b8c784dda3
68 changed files with 2359 additions and 1139 deletions

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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 */}

View file

@ -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)}
/> />

View file

@ -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",
}; };

View file

@ -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) => {

View file

@ -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"

View file

@ -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;
} }

View file

@ -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();
});
});

View file

@ -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>
);
}

View file

@ -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

View 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 };

View file

@ -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 */

View file

@ -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;

View 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;
}

View 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 };

View file

@ -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>
) )

View file

@ -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>

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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");
} }
}, },

View file

@ -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>

View file

@ -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;

View file

@ -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 };
} }

View file

@ -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;

View file

@ -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",

View file

@ -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" });
}
} }

View file

@ -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") });

View 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 };
}

View file

@ -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[];
} }

View 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;
}

View file

@ -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;
} }

View file

@ -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;
}

View file

@ -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
View file

@ -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)

View file

@ -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
} }

View file

@ -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)

View file

@ -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

View file

@ -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=

View 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)
}

View file

@ -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],
}) })
} }

View file

@ -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
} }

View file

@ -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,

View file

@ -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)

View file

@ -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")

View file

@ -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)
}

View 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)
}

View file

@ -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,
} }
} }

View file

@ -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 {

View file

@ -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)

View 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)
})
}
}

View file

@ -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)

View 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
}

View 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
}

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS attachment;

View 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);

View 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);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS daemon_pairing_session;

View file

@ -0,0 +1 @@
ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'workspace';

View file

@ -0,0 +1 @@
ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'private';

View file

@ -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')

View 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
}

View file

@ -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"`

View file

@ -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')

View 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;