From 7dafb5127db7f3286a5fcc9e7c73f3d9ef4f968b Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:33:09 +0800 Subject: [PATCH 01/36] feat(issues): add collapsible toggle for comment replies Wrap the replies section in a Collapsible component so users can collapse/expand replies on a comment thread. The parent comment and reply input remain always visible. A chevron trigger shows the reply count (e.g. "3 replies") and rotates on open. Default state is expanded to preserve existing behavior. --- .../issues/components/comment-card.tsx | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index eef902d4..5596c7cf 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { MoreHorizontal } from "lucide-react"; +import { MoreHorizontal, ChevronRight } from "lucide-react"; import { toast } from "sonner"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -13,9 +13,11 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { ReactionBar } from "@/components/common/reaction-bar"; import { Markdown } from "@/components/markdown"; +import { cn } from "@/lib/utils"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; import { ReplyInput } from "./reply-input"; @@ -173,6 +175,8 @@ function CommentCard({ onDelete, onToggleReaction, }: CommentCardProps) { + const [repliesOpen, setRepliesOpen] = useState(true); + // Collect all nested replies recursively into a flat list const allNestedReplies: TimelineEntry[] = []; const collectReplies = (parentId: string) => { @@ -184,6 +188,8 @@ function CommentCard({ }; collectReplies(entry.id); + const replyCount = allNestedReplies.length; + return ( {/* Parent comment */} @@ -197,18 +203,32 @@ function CommentCard({ /> - {/* Replies — flat, separated by border */} - {allNestedReplies.map((reply) => ( -
- -
- ))} + {/* Replies — collapsible when there are replies */} + {replyCount > 0 && ( + +
+ + + + {replyCount} {replyCount === 1 ? "reply" : "replies"} + + +
+ + {allNestedReplies.map((reply) => ( +
+ +
+ ))} +
+
+ )} {/* Reply input — always visible at bottom */}
From bedf4a05c82cff86bc6cb000ae2e98a13aaa4781 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:34:04 +0800 Subject: [PATCH 02/36] fix(agent): fix agent visibility defaults and permission model - Change DB default for agent visibility from 'workspace' to 'private' - Fix canManageAgent: workspace agents are now manageable by all members, private agents remain restricted to owner/admin - Add private agent visibility check to BatchAssigneePicker (was missing) --- .../components/batch-action-toolbar.tsx | 56 +++++++++++++------ server/internal/handler/agent.go | 13 ++--- .../028_agent_default_private.down.sql | 1 + .../028_agent_default_private.up.sql | 1 + 4 files changed, 46 insertions(+), 25 deletions(-) create mode 100644 server/migrations/028_agent_default_private.down.sql create mode 100644 server/migrations/028_agent_default_private.up.sql diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index edf56959..e52365f4 100644 --- a/apps/web/features/issues/components/batch-action-toolbar.tsx +++ b/apps/web/features/issues/components/batch-action-toolbar.tsx @@ -1,7 +1,7 @@ "use client"; 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 { Button } from "@/components/ui/button"; import { @@ -19,8 +19,9 @@ import { PopoverTrigger, PopoverContent, } 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 { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useIssueStore } from "@/features/issues/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({ open, onOpenChange, @@ -218,10 +226,14 @@ function BatchAssigneePicker({ loading: boolean; }) { const [filter, setFilter] = useState(""); + const user = useAuthStore((s) => s.user); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); const { getActorInitials } = useActorName(); + const currentMember = members.find((m) => m.user_id === user?.id); + const memberRole = currentMember?.role; + const query = filter.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(query), @@ -297,22 +309,30 @@ function BatchAssigneePicker({
Agents
- {filteredAgents.map((a) => ( - - ))} + {filteredAgents.map((a) => { + const allowed = canAssignAgent(a, user?.id, memberRole); + return ( + + ); + })}
)} diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index 9ad40468..c510d6d9 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -326,24 +326,23 @@ type UpdateAgentRequest struct { } // canManageAgent checks whether the current user can update or delete an agent. -// Workspace-visible agents require owner/admin role. Private agents additionally -// require the user to be the agent's owner (or a workspace owner/admin). +// Workspace-visible agents can be managed by any workspace member. +// 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 { wsID := uuidToString(agent.WorkspaceID) member, ok := h.requireWorkspaceRole(w, r, wsID, "agent not found", "owner", "admin", "member") if !ok { return false } + if agent.Visibility != "private" { + return true + } isAdmin := roleAllowed(member.Role, "owner", "admin") 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") return false } - if agent.Visibility != "private" && !isAdmin && !isAgentOwner { - writeError(w, http.StatusForbidden, "insufficient permissions") - return false - } return true } diff --git a/server/migrations/028_agent_default_private.down.sql b/server/migrations/028_agent_default_private.down.sql new file mode 100644 index 00000000..a7ea0a37 --- /dev/null +++ b/server/migrations/028_agent_default_private.down.sql @@ -0,0 +1 @@ +ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'workspace'; diff --git a/server/migrations/028_agent_default_private.up.sql b/server/migrations/028_agent_default_private.up.sql new file mode 100644 index 00000000..7b85faef --- /dev/null +++ b/server/migrations/028_agent_default_private.up.sql @@ -0,0 +1 @@ +ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'private'; From f69aa93a757ea09e61bb2ff0182904085d15907e Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:40:53 +0800 Subject: [PATCH 03/36] feat(agents): add Settings tab for editing agent visibility and properties Adds a Settings tab to the agent detail panel with: - Name and description editing - Visibility toggle (workspace/private) matching the create dialog pattern - Max concurrent tasks configuration - Runtime info display (read-only) --- apps/web/app/(dashboard)/agents/page.tsx | 143 ++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 902cbcf4..dce100c4 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -27,6 +27,7 @@ import { ChevronDown, Globe, Lock, + Settings, } from "lucide-react"; import type { Agent, @@ -1151,11 +1152,143 @@ function TasksTab({ agent }: { agent: Agent }) { ); } +// --------------------------------------------------------------------------- +// Settings Tab +// --------------------------------------------------------------------------- + +function SettingsTab({ + agent, + runtimes, + onSave, +}: { + agent: Agent; + runtimes: RuntimeDevice[]; + onSave: (updates: Partial) => Promise; +}) { + const [name, setName] = useState(agent.name); + const [description, setDescription] = useState(agent.description ?? ""); + const [visibility, setVisibility] = useState(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 ( +
+
+ + setName(e.target.value)} + className="mt-1" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="What does this agent do?" + className="mt-1" + /> +
+ +
+ +
+ + +
+
+ +
+ + setMaxTasks(Number(e.target.value))} + className="mt-1 w-24" + /> +
+ +
+ +
+ {agent.runtime_mode === "cloud" ? ( + + ) : ( + + )} + {runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")} +
+
+ + +
+ ); +} + // --------------------------------------------------------------------------- // 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 }[] = [ { id: "instructions", label: "Instructions", icon: FileText }, @@ -1163,6 +1296,7 @@ const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [ { id: "tools", label: "Tools", icon: Wrench }, { id: "triggers", label: "Triggers", icon: Timer }, { id: "tasks", label: "Tasks", icon: ListTodo }, + { id: "settings", label: "Settings", icon: Settings }, ]; function AgentDetail({ @@ -1267,6 +1401,13 @@ function AgentDetail({ /> )} {activeTab === "tasks" && } + {activeTab === "settings" && ( + onUpdate(agent.id, updates)} + /> + )} {/* Delete Confirmation */} From 29a80e057e01bc8066b6a5575350baf0d5fd9f6e Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 14:41:17 +0800 Subject: [PATCH 04/36] feat(upload): add file upload API with S3 + CloudFront signed cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /api/upload-file endpoint that uploads files to S3 and returns CDN URLs protected by CloudFront signed cookies (same pattern as Linear). Infrastructure: - Two private S3 buckets (static.multica.ai, static-staging.multica.ai) - Two CloudFront distributions with OAC and Trusted Key Groups - ACM wildcard cert in us-east-1, DNS records in Route 53 - RSA signing key stored in AWS Secrets Manager Backend: - S3 storage service with CloudFront CDN domain support - CloudFront signed cookie generation (RSA-SHA1) - Private key loaded from Secrets Manager (env var fallback for local dev) - Cookies set on login (VerifyCode) with 72h expiry matching JWT - Upload handler: multipart form → S3 → CloudFront URL response Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 9 ++ server/cmd/server/router.go | 7 +- server/go.mod | 20 +++ server/go.sum | 40 +++++ server/internal/auth/cloudfront.go | 185 ++++++++++++++++++++++++ server/internal/handler/auth.go | 7 + server/internal/handler/file.go | 60 ++++++++ server/internal/handler/handler.go | 8 +- server/internal/handler/handler_test.go | 2 +- server/internal/storage/s3.go | 90 ++++++++++++ 10 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 server/internal/auth/cloudfront.go create mode 100644 server/internal/handler/file.go create mode 100644 server/internal/storage/s3.go diff --git a/.env.example b/.env.example index 1c0c93ab..e627d3f9 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,15 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= 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_PORT=3000 FRONTEND_ORIGIN=http://localhost:3000 diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 2de70b1e..e65b6b9a 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -12,11 +12,13 @@ import ( "github.com/jackc/pgx/v5/pgtype" "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/handler" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" "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" ) @@ -47,7 +49,9 @@ func allowedOrigins() []string { func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router { queries := db.New(pool) 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() @@ -110,6 +114,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route // --- User-scoped routes (no workspace context required) --- r.Get("/api/me", h.GetMe) r.Patch("/api/me", h.UpdateMe) + r.Post("/api/upload-file", h.UploadFile) r.Route("/api/workspaces", func(r chi.Router) { r.Get("/", h.ListWorkspaces) diff --git a/server/go.mod b/server/go.mod index 05c33813..2cdbd6d4 100644 --- a/server/go.mod +++ b/server/go.mod @@ -13,6 +13,26 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // 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/s3 v1.97.3 // indirect + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 // 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/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/server/go.sum b/server/go.sum index da00dcd6..7017544c 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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/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= diff --git a/server/internal/auth/cloudfront.go b/server/internal/auth/cloudfront.go new file mode 100644 index 00000000..03396aac --- /dev/null +++ b/server/internal/auth/cloudfront.go @@ -0,0 +1,185 @@ +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 +} + +// Domain returns the CDN domain (e.g. "static.multica.ai"). +func (s *CloudFrontSigner) Domain() string { + return s.domain +} + +// 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), + } +} + +// 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) +} diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index e073ef82..5339190c 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -300,6 +300,13 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { 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)...) writeJSON(w, http.StatusOK, LoginResponse{ Token: tokenString, diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go new file mode 100644 index 00000000..e47c7423 --- /dev/null +++ b/server/internal/handler/file.go @@ -0,0 +1,60 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "log/slog" + "net/http" + "path" +) + +func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { + if h.Storage == nil { + writeError(w, http.StatusServiceUnavailable, "file upload not configured") + return + } + + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeError(w, http.StatusBadRequest, "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() + + data, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to read file: %v", err)) + 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) + + contentType := header.Header.Get("Content-Type") + link, err := h.Storage.Upload(r.Context(), key, data, contentType, map[string]string{ + "filename": header.Filename, + }) + if err != nil { + slog.Error("file upload failed", "error", err) + writeError(w, http.StatusInternalServerError, "upload failed") + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "filename": header.Filename, + "link": link, + }) +} diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index d8086a79..cdf81027 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -12,10 +12,12 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" 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/middleware" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" + "github.com/multica-ai/multica/server/internal/storage" "github.com/multica-ai/multica/server/internal/util" ) @@ -38,9 +40,11 @@ type Handler struct { TaskService *service.TaskService EmailService *service.EmailService 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 if candidate, ok := txStarter.(dbExecutor); ok { executor = candidate @@ -55,6 +59,8 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event TaskService: service.NewTaskService(queries, hub, bus), EmailService: emailService, PingStore: NewPingStore(), + Storage: s3, + CFSigner: cfSigner, } } diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index daf68e0a..15e1769b 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -53,7 +53,7 @@ func TestMain(m *testing.M) { go hub.Run() bus := events.New() emailSvc := service.NewEmailService() - testHandler = New(queries, pool, hub, bus, emailSvc) + testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil) testPool = pool testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool) diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go new file mode 100644 index 00000000..f839222f --- /dev/null +++ b/server/internal/storage/s3.go @@ -0,0 +1,90 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + + "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, + } +} + +func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, metadata map[string]string) (string, error) { + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + CacheControl: aws.String("max-age=432000,public"), + StorageClass: types.StorageClassIntelligentTiering, + Metadata: metadata, + }) + 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 +} From d729f9c5bee41c066cdb6b179e858e121f9818bd Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:41:44 +0800 Subject: [PATCH 05/36] feat(issues): make entire comment card collapsible with toggle Each comment card now has a clickable header with a chevron toggle. When collapsed, shows author, timestamp, and a content preview. When expanded, shows the full comment body, replies, and reply input. --- .../issues/components/comment-card.tsx | 108 ++++++++++-------- 1 file changed, 62 insertions(+), 46 deletions(-) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index d47feb29..d425e1dc 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -191,7 +191,8 @@ function CommentCard({ onDelete, onToggleReaction, }: CommentCardProps) { - const [repliesOpen, setRepliesOpen] = useState(true); + const { getActorName } = useActorName(); + const [open, setOpen] = useState(true); // Collect all nested replies recursively into a flat list const allNestedReplies: TimelineEntry[] = []; @@ -205,57 +206,72 @@ function CommentCard({ collectReplies(entry.id); const replyCount = allNestedReplies.length; + const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80); return ( - {/* Parent comment */} -
- -
- - {/* Replies — collapsible when there are replies */} - {replyCount > 0 && ( - -
- - - + + {/* Collapsed header — always visible */} +
+ + + + + {getActorName(entry.actor_type, entry.actor_id)} + + + {timeAgo(entry.created_at)} + + {!open && contentPreview && ( + + {contentPreview}{(entry.content ?? "").length > 80 ? "..." : ""} + + )} + {!open && replyCount > 0 && ( + {replyCount} {replyCount === 1 ? "reply" : "replies"} - -
- - {allNestedReplies.map((reply) => ( -
- -
- ))} -
-
- )} + )} +
+
- {/* Reply input — always visible at bottom */} -
- onReply(entry.id, content)} - /> -
+ {/* Expanded content */} + +
+ +
+ + {/* Replies */} + {allNestedReplies.map((reply) => ( +
+ +
+ ))} + + {/* Reply input */} +
+ onReply(entry.id, content)} + /> +
+
+
); } From 26064e43d194b0f485b8ba5a66e2c6c43fda3af2 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 31 Mar 2026 14:52:30 +0800 Subject: [PATCH 06/36] feat(web): add OK emoji to reaction quick bar --- apps/web/components/common/reaction-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/common/reaction-bar.tsx b/apps/web/components/common/reaction-bar.tsx index fa98a5fc..f59908b6 100644 --- a/apps/web/components/common/reaction-bar.tsx +++ b/apps/web/components/common/reaction-bar.tsx @@ -10,7 +10,7 @@ const EmojiPicker = lazy(() => import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })), ); -const QUICK_EMOJIS = ["👍", "👎", "❤️", "😄", "🎉", "😕", "🚀", "👀"]; +const QUICK_EMOJIS = ["👍", "👎", "❤️", "😄", "🎉", "😕", "🚀", "👀", "👌"]; interface ReactionItem { id: string; From 06424f9ba659b2915de5b1fc4bb163cfb9d4796b Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:53:05 +0800 Subject: [PATCH 07/36] fix(daemon): add CLI hint to issue_context.md renderIssueContext() now includes a "Quick Start" section with the `multica issue get` command so agents know how to fetch issue details. Fixes the TestPrepareDirectoryMode and TestWriteContextFiles failures. --- server/internal/daemon/execenv/context.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index 58d5e999..c59d56e9 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -120,6 +120,9 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string { b.WriteString("**Trigger:** New Assignment\n\n") } + b.WriteString("## Quick Start\n\n") + fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID) + if len(ctx.AgentSkills) > 0 { b.WriteString("## Agent Skills\n\n") b.WriteString("The following skills are available to you:\n\n") From edf4c00c08727ca8f2bbd37797bb1f11d85aa652 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 14:55:27 +0800 Subject: [PATCH 08/36] fix(upload): add file type/size validation, Content-Disposition header - Add content type allowlist (images, PDF, text, video, audio, zip) - Enforce 10 MB upload limit via http.MaxBytesReader - Set Content-Disposition on S3 objects for proper download filenames - Remove unused CloudFrontSigner.Domain() method Co-Authored-By: Claude Opus 4.6 (1M context) --- server/go.mod | 12 ++++---- server/internal/auth/cloudfront.go | 5 ---- server/internal/handler/file.go | 47 +++++++++++++++++++++++++----- server/internal/storage/s3.go | 16 +++++----- 4 files changed, 54 insertions(+), 26 deletions(-) diff --git a/server/go.mod b/server/go.mod index 2cdbd6d4..30725711 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,20 +3,23 @@ module github.com/multica-ai/multica/server go 1.26.1 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/cors v1.2.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 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/spf13/cobra v1.10.2 ) require ( - github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // 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 @@ -26,8 +29,6 @@ require ( 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/s3 v1.97.3 // indirect - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 // 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 @@ -37,7 +38,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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 golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/server/internal/auth/cloudfront.go b/server/internal/auth/cloudfront.go index 03396aac..ee255378 100644 --- a/server/internal/auth/cloudfront.go +++ b/server/internal/auth/cloudfront.go @@ -137,11 +137,6 @@ func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { return rsaKey, nil } -// Domain returns the CDN domain (e.g. "static.multica.ai"). -func (s *CloudFrontSigner) Domain() string { - return s.domain -} - // 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()) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index e47c7423..ab7882ee 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -8,16 +8,46 @@ import ( "log/slog" "net/http" "path" + "strings" ) +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] +} + func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { if h.Storage == nil { writeError(w, http.StatusServiceUnavailable, "file upload not configured") return } - if err := r.ParseMultipartForm(32 << 20); err != nil { - writeError(w, http.StatusBadRequest, "invalid multipart form") + 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() @@ -29,9 +59,15 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { } defer file.Close() + contentType := header.Header.Get("Content-Type") + if !isContentTypeAllowed(contentType) { + writeError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType)) + return + } + data, err := io.ReadAll(file) if err != nil { - writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to read file: %v", err)) + writeError(w, http.StatusBadRequest, "failed to read file") return } @@ -43,10 +79,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { } key := hex.EncodeToString(b) + path.Ext(header.Filename) - contentType := header.Header.Get("Content-Type") - link, err := h.Storage.Upload(r.Context(), key, data, contentType, map[string]string{ - "filename": 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") diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go index f839222f..3f03ea86 100644 --- a/server/internal/storage/s3.go +++ b/server/internal/storage/s3.go @@ -67,15 +67,15 @@ func NewS3StorageFromEnv() *S3Storage { } } -func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, metadata map[string]string) (string, error) { +func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) { _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(key), - Body: bytes.NewReader(data), - ContentType: aws.String(contentType), - CacheControl: aws.String("max-age=432000,public"), - StorageClass: types.StorageClassIntelligentTiering, - Metadata: metadata, + 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"`, filename)), + CacheControl: aws.String("max-age=432000,public"), + StorageClass: types.StorageClassIntelligentTiering, }) if err != nil { return "", fmt.Errorf("s3 PutObject: %w", err) From c27b7bab5eceade3657d579fe0219f024aa40c6d Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 14:58:52 +0800 Subject: [PATCH 09/36] fix(upload): sniff content type, sanitize filename, add key prefix - Use http.DetectContentType() instead of trusting client-declared MIME type - Sanitize quotes in filename for Content-Disposition header injection - Add uploads/ prefix to S3 keys for better organization Co-Authored-By: Claude Opus 4.6 (1M context) --- server/internal/handler/file.go | 16 ++++++++++++++-- server/internal/storage/s3.go | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index ab7882ee..096e9a13 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -59,11 +59,23 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { } defer file.Close() - contentType := header.Header.Get("Content-Type") + // 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 { @@ -77,7 +89,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "internal error") return } - key := hex.EncodeToString(b) + path.Ext(header.Filename) + key := "uploads/" + hex.EncodeToString(b) + path.Ext(header.Filename) link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename) if err != nil { diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go index 3f03ea86..b29375f0 100644 --- a/server/internal/storage/s3.go +++ b/server/internal/storage/s3.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -73,7 +74,7 @@ func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, content Key: aws.String(key), Body: bytes.NewReader(data), ContentType: aws.String(contentType), - ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, filename)), + ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, strings.ReplaceAll(filename, `"`, "'"))), CacheControl: aws.String("max-age=432000,public"), StorageClass: types.StorageClassIntelligentTiering, }) From 978a5af5de945422535cad057fc936737619fab0 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 15:01:33 +0800 Subject: [PATCH 10/36] fix(upload): remove unnecessary uploads/ key prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-purpose bucket with randomized hex keys doesn't benefit from a prefix — no lifecycle policies or access controls scoped to it. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/internal/handler/file.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index 096e9a13..230c0336 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -89,7 +89,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "internal error") return } - key := "uploads/" + hex.EncodeToString(b) + path.Ext(header.Filename) + key := hex.EncodeToString(b) + path.Ext(header.Filename) link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename) if err != nil { From 8395479653d0480644a8247237fd98df65671031 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:07:37 +0800 Subject: [PATCH 11/36] feat(inbox): add archive button on individual inbox list items Show an archive icon on hover for each inbox list item, allowing users to archive a single message directly from the list without needing to open the detail panel first. --- apps/web/app/(dashboard)/inbox/page.tsx | 31 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index a4743a14..9d3b62d2 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -145,15 +145,17 @@ function InboxListItem({ item, isSelected, onClick, + onArchive, }: { item: InboxItem; isSelected: boolean; onClick: () => void; + onArchive: () => void; }) { return ( + } + /> + + { + navigator.clipboard.writeText(entry.content ?? ""); + toast.success("Copied"); + }}> + + Copy + + {isOwn && ( + <> + + + + Edit + + + onDelete(entry.id)} variant="destructive"> + + Delete + + + )} + + + )} + - {/* Expanded content */} + {/* Collapsible body */} -
- + {/* Parent comment body */} +
+ {editing ? ( +
{ e.preventDefault(); saveEdit(); }} + className="pl-10" + > + 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(); }} + /> +
+ + +
+
+ ) : ( + <> +
+ {entry.content ?? ""} +
+ {!isTemp && ( + onToggleReaction(entry.id, emoji)} + className="mt-1.5 pl-10" + /> + )} + + )}
{/* Replies */} From 1054e218ed6d9ac24053c93addaf3a54720da713 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:15:06 +0800 Subject: [PATCH 13/36] fix(daemon): update execenv tests to match current renderIssueContext output CLI hints like "multica issue get" were moved to CLAUDE.md and are no longer rendered into issue_context.md. Remove stale assertions. --- server/internal/daemon/execenv/execenv_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index db3611f1..f163ef47 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -103,7 +103,7 @@ func TestPrepareDirectoryMode(t *testing.T) { if err != nil { t.Fatalf("failed to read issue_context.md: %v", err) } - for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} { + for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "Code Review"} { if !strings.Contains(string(content), want) { t.Fatalf("issue_context.md missing %q", want) } @@ -208,7 +208,6 @@ func TestWriteContextFiles(t *testing.T) { s := string(content) for _, want := range []string{ "test-issue-id-1234", - "multica issue get", "## Agent Skills", "Go Conventions", } { From 9f56f6af81b787d93cb623e2fc14c76b3aaad099 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:41:26 +0800 Subject: [PATCH 14/36] feat(daemon): add authentication for daemon API routes Issue daemon auth tokens (mdt_) on pairing session claim, bound to workspace_id + daemon_id with 1-year expiry. Add DaemonAuth middleware that validates these tokens and falls back to JWT/PAT for backward compatibility. Apply middleware to all daemon routes except pairing endpoints. --- server/cmd/server/router.go | 36 ++++--- server/internal/auth/jwt.go | 9 ++ server/internal/handler/daemon_pairing.go | 40 ++++++- server/internal/middleware/daemon_auth.go | 112 ++++++++++++++++++++ server/migrations/028_daemon_token.down.sql | 1 + server/migrations/028_daemon_token.up.sql | 11 ++ server/pkg/db/generated/daemon_token.sql.go | 88 +++++++++++++++ server/pkg/db/generated/models.go | 9 ++ server/pkg/db/queries/daemon_token.sql | 16 +++ 9 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 server/internal/middleware/daemon_auth.go create mode 100644 server/migrations/028_daemon_token.down.sql create mode 100644 server/migrations/028_daemon_token.up.sql create mode 100644 server/pkg/db/generated/daemon_token.sql.go create mode 100644 server/pkg/db/queries/daemon_token.sql diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 2de70b1e..0f70001d 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -79,28 +79,34 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Post("/auth/send-code", h.SendCode) r.Post("/auth/verify-code", h.VerifyCode) - // Daemon API routes (no user auth; daemon auth deferred to later) + // Daemon API routes r.Route("/api/daemon", func(r chi.Router) { + // Pairing routes — no auth required (daemon doesn't have a token yet). r.Post("/pairing-sessions", h.CreateDaemonPairingSession) r.Get("/pairing-sessions/{token}", h.GetDaemonPairingSession) r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession) - r.Post("/register", h.DaemonRegister) - r.Post("/deregister", h.DaemonDeregister) - r.Post("/heartbeat", h.DaemonHeartbeat) + // Authenticated daemon routes — require daemon token (mdt_) or user JWT/PAT. + r.Group(func(r chi.Router) { + r.Use(middleware.DaemonAuth(queries)) - r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) - r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime) - r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage) - r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult) + r.Post("/register", h.DaemonRegister) + r.Post("/deregister", h.DaemonDeregister) + r.Post("/heartbeat", h.DaemonHeartbeat) - r.Get("/tasks/{taskId}/status", h.GetTaskStatus) - r.Post("/tasks/{taskId}/start", h.StartTask) - r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress) - r.Post("/tasks/{taskId}/complete", h.CompleteTask) - r.Post("/tasks/{taskId}/fail", h.FailTask) - r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages) - r.Get("/tasks/{taskId}/messages", h.ListTaskMessages) + r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) + r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime) + r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage) + r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult) + + r.Get("/tasks/{taskId}/status", h.GetTaskStatus) + r.Post("/tasks/{taskId}/start", h.StartTask) + r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress) + r.Post("/tasks/{taskId}/complete", h.CompleteTask) + r.Post("/tasks/{taskId}/fail", h.FailTask) + r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages) + r.Get("/tasks/{taskId}/messages", h.ListTaskMessages) + }) }) // Protected API routes diff --git a/server/internal/auth/jwt.go b/server/internal/auth/jwt.go index 6ad212ff..f300ed70 100644 --- a/server/internal/auth/jwt.go +++ b/server/internal/auth/jwt.go @@ -37,6 +37,15 @@ func GeneratePATToken() (string, error) { return "mul_" + hex.EncodeToString(b), nil } +// GenerateDaemonToken creates a new daemon auth token: "mdt_" + 40 random hex chars. +func GenerateDaemonToken() (string, error) { + b := make([]byte, 20) // 20 bytes = 40 hex chars + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate daemon token: %w", err) + } + return "mdt_" + hex.EncodeToString(b), nil +} + // HashToken returns the hex-encoded SHA-256 hash of a token string. func HashToken(token string) string { h := sha256.Sum256([]byte(token)) diff --git a/server/internal/handler/daemon_pairing.go b/server/internal/handler/daemon_pairing.go index 9cf7747f..a8bfcfda 100644 --- a/server/internal/handler/daemon_pairing.go +++ b/server/internal/handler/daemon_pairing.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log/slog" "net/http" "net/url" "os" @@ -14,6 +15,8 @@ import ( "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 @@ -50,6 +53,7 @@ type DaemonPairingSessionResponse struct { 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 { @@ -382,5 +386,39 @@ func (h *Handler) ClaimDaemonPairingSession(w http.ResponseWriter, r *http.Reque return } - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) + 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) } diff --git a/server/internal/middleware/daemon_auth.go b/server/internal/middleware/daemon_auth.go new file mode 100644 index 00000000..d91282cf --- /dev/null +++ b/server/internal/middleware/daemon_auth.go @@ -0,0 +1,112 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/multica-ai/multica/server/internal/auth" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// Daemon context keys. +type daemonContextKey int + +const ( + ctxKeyDaemonWorkspaceID daemonContextKey = iota + ctxKeyDaemonID +) + +// DaemonWorkspaceIDFromContext returns the workspace ID set by DaemonAuth middleware. +func DaemonWorkspaceIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(ctxKeyDaemonWorkspaceID).(string) + return id +} + +// DaemonIDFromContext returns the daemon ID set by DaemonAuth middleware. +func DaemonIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(ctxKeyDaemonID).(string) + return id +} + +// DaemonAuth validates daemon auth tokens (mdt_ prefix) or falls back to +// JWT/PAT validation for backward compatibility with daemons that +// authenticate via user tokens. +func DaemonAuth(queries *db.Queries) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + slog.Debug("daemon_auth: missing authorization header", "path", r.URL.Path) + writeError(w, http.StatusUnauthorized, "missing authorization header") + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + slog.Debug("daemon_auth: invalid format", "path", r.URL.Path) + writeError(w, http.StatusUnauthorized, "invalid authorization format") + return + } + + // Daemon token: "mdt_" prefix. + if strings.HasPrefix(tokenString, "mdt_") { + hash := auth.HashToken(tokenString) + dt, err := queries.GetDaemonTokenByHash(r.Context(), hash) + if err != nil { + slog.Warn("daemon_auth: invalid daemon token", "path", r.URL.Path, "error", err) + writeError(w, http.StatusUnauthorized, "invalid daemon token") + return + } + + ctx := context.WithValue(r.Context(), ctxKeyDaemonWorkspaceID, uuidToString(dt.WorkspaceID)) + ctx = context.WithValue(ctx, ctxKeyDaemonID, dt.DaemonID) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Fallback: PAT tokens ("mul_" prefix). + if strings.HasPrefix(tokenString, "mul_") { + hash := auth.HashToken(tokenString) + pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash) + if err != nil { + slog.Warn("daemon_auth: invalid PAT", "path", r.URL.Path, "error", err) + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + r.Header.Set("X-User-ID", uuidToString(pat.UserID)) + go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID) + next.ServeHTTP(w, r) + return + } + + // Fallback: JWT tokens. + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return auth.JWTSecret(), nil + }) + if err != nil || !token.Valid { + slog.Warn("daemon_auth: invalid token", "path", r.URL.Path, "error", err) + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + writeError(w, http.StatusUnauthorized, "invalid claims") + return + } + sub, ok := claims["sub"].(string) + if !ok || strings.TrimSpace(sub) == "" { + writeError(w, http.StatusUnauthorized, "invalid claims") + return + } + r.Header.Set("X-User-ID", sub) + next.ServeHTTP(w, r) + }) + } +} diff --git a/server/migrations/028_daemon_token.down.sql b/server/migrations/028_daemon_token.down.sql new file mode 100644 index 00000000..18600acc --- /dev/null +++ b/server/migrations/028_daemon_token.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS daemon_token; diff --git a/server/migrations/028_daemon_token.up.sql b/server/migrations/028_daemon_token.up.sql new file mode 100644 index 00000000..6704aa08 --- /dev/null +++ b/server/migrations/028_daemon_token.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE daemon_token ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash TEXT NOT NULL, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + daemon_id TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX idx_daemon_token_hash ON daemon_token(token_hash); +CREATE INDEX idx_daemon_token_workspace_daemon ON daemon_token(workspace_id, daemon_id); diff --git a/server/pkg/db/generated/daemon_token.sql.go b/server/pkg/db/generated/daemon_token.sql.go new file mode 100644 index 00000000..367d7504 --- /dev/null +++ b/server/pkg/db/generated/daemon_token.sql.go @@ -0,0 +1,88 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: daemon_token.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createDaemonToken = `-- name: CreateDaemonToken :one +INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at +` + +type CreateDaemonTokenParams struct { + TokenHash string `json:"token_hash"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + DaemonID string `json:"daemon_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreateDaemonToken(ctx context.Context, arg CreateDaemonTokenParams) (DaemonToken, error) { + row := q.db.QueryRow(ctx, createDaemonToken, + arg.TokenHash, + arg.WorkspaceID, + arg.DaemonID, + arg.ExpiresAt, + ) + var i DaemonToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.WorkspaceID, + &i.DaemonID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const deleteDaemonTokensByWorkspaceAndDaemon = `-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec +DELETE FROM daemon_token +WHERE workspace_id = $1 AND daemon_id = $2 +` + +type DeleteDaemonTokensByWorkspaceAndDaemonParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + DaemonID string `json:"daemon_id"` +} + +func (q *Queries) DeleteDaemonTokensByWorkspaceAndDaemon(ctx context.Context, arg DeleteDaemonTokensByWorkspaceAndDaemonParams) error { + _, err := q.db.Exec(ctx, deleteDaemonTokensByWorkspaceAndDaemon, arg.WorkspaceID, arg.DaemonID) + return err +} + +const deleteExpiredDaemonTokens = `-- name: DeleteExpiredDaemonTokens :exec +DELETE FROM daemon_token +WHERE expires_at <= now() +` + +func (q *Queries) DeleteExpiredDaemonTokens(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteExpiredDaemonTokens) + return err +} + +const getDaemonTokenByHash = `-- name: GetDaemonTokenByHash :one +SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at FROM daemon_token +WHERE token_hash = $1 AND expires_at > now() +` + +func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (DaemonToken, error) { + row := q.db.QueryRow(ctx, getDaemonTokenByHash, tokenHash) + var i DaemonToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.WorkspaceID, + &i.DaemonID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 9547212e..78a736c5 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -131,6 +131,15 @@ type DaemonPairingSession struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type DaemonToken struct { + ID pgtype.UUID `json:"id"` + TokenHash string `json:"token_hash"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + DaemonID string `json:"daemon_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type InboxItem struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` diff --git a/server/pkg/db/queries/daemon_token.sql b/server/pkg/db/queries/daemon_token.sql new file mode 100644 index 00000000..252b17f2 --- /dev/null +++ b/server/pkg/db/queries/daemon_token.sql @@ -0,0 +1,16 @@ +-- name: CreateDaemonToken :one +INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetDaemonTokenByHash :one +SELECT * FROM daemon_token +WHERE token_hash = $1 AND expires_at > now(); + +-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec +DELETE FROM daemon_token +WHERE workspace_id = $1 AND daemon_id = $2; + +-- name: DeleteExpiredDaemonTokens :exec +DELETE FROM daemon_token +WHERE expires_at <= now(); From 497fce0061ecd180a6300c2ebe374ad92d8c0d17 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:12:16 +0800 Subject: [PATCH 15/36] fix(tests): merge main, renumber migration, fix execenv test assertions Merge main to pick up 028_task_trigger_comment migration. Renumber daemon_token migration to 029. Fix execenv tests that expected CLI hints in issue_context.md after they were moved to CLAUDE.md. --- server/internal/daemon/execenv/execenv_test.go | 3 +-- .../{028_daemon_token.down.sql => 029_daemon_token.down.sql} | 0 .../{028_daemon_token.up.sql => 029_daemon_token.up.sql} | 0 3 files changed, 1 insertion(+), 2 deletions(-) rename server/migrations/{028_daemon_token.down.sql => 029_daemon_token.down.sql} (100%) rename server/migrations/{028_daemon_token.up.sql => 029_daemon_token.up.sql} (100%) diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index db3611f1..f163ef47 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -103,7 +103,7 @@ func TestPrepareDirectoryMode(t *testing.T) { if err != nil { t.Fatalf("failed to read issue_context.md: %v", err) } - for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} { + for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "Code Review"} { if !strings.Contains(string(content), want) { t.Fatalf("issue_context.md missing %q", want) } @@ -208,7 +208,6 @@ func TestWriteContextFiles(t *testing.T) { s := string(content) for _, want := range []string{ "test-issue-id-1234", - "multica issue get", "## Agent Skills", "Go Conventions", } { diff --git a/server/migrations/028_daemon_token.down.sql b/server/migrations/029_daemon_token.down.sql similarity index 100% rename from server/migrations/028_daemon_token.down.sql rename to server/migrations/029_daemon_token.down.sql diff --git a/server/migrations/028_daemon_token.up.sql b/server/migrations/029_daemon_token.up.sql similarity index 100% rename from server/migrations/028_daemon_token.up.sql rename to server/migrations/029_daemon_token.up.sql From 423aa3888836d226059674f2dabe4f6d093f8093 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 15:17:54 +0800 Subject: [PATCH 16/36] =?UTF-8?q?feat(upload):=20add=20file=20upload=20UI?= =?UTF-8?q?=20=E2=80=94=20avatar,=20editor=20paste/drop,=20attachments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add uploadFile method to ApiClient (FormData + 401 handling) - Add useFileUpload hook with client-side validation - ActorAvatar renders actual avatar images with fallback to initials - Account settings: replace URL input with clickable avatar upload - RichTextEditor: add Image extension, paste/drop/insertFile support - Markdown renderer: add img component for uploaded images - CommentInput & ReplyInput: add paperclip button for file attachments - Issue description: paste/drop file upload support Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/issues/[id]/page.test.tsx | 1 + apps/web/app/(dashboard)/issues/page.test.tsx | 1 + .../settings/_components/account-tab.tsx | 88 ++++++++++++++---- apps/web/components/common/actor-avatar.tsx | 25 +++++- .../components/common/rich-text-editor.tsx | 89 +++++++++++++++++++ apps/web/components/markdown/Markdown.tsx | 9 ++ .../issues/components/comment-input.tsx | 44 ++++++++- .../issues/components/issue-detail.tsx | 15 ++++ .../issues/components/reply-input.tsx | 45 +++++++++- apps/web/features/workspace/hooks.ts | 8 +- apps/web/hooks/use-file-upload.ts | 58 ++++++++++++ apps/web/package.json | 1 + apps/web/shared/api/client.ts | 39 ++++++++ pnpm-lock.yaml | 12 +++ 14 files changed, 409 insertions(+), 26 deletions(-) create mode 100644 apps/web/hooks/use-file-upload.ts diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 8e27d5e1..77739728 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -58,6 +58,7 @@ vi.mock("@/features/workspace", () => ({ if (type === "agent") return "CA"; return "??"; }, + getActorAvatarUrl: () => null, }), })); diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index c7af7dcc..5e9d662e 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/features/workspace", () => ({ getActorName: (type: string, id: string) => type === "member" ? "Test User" : "Claude Agent", getActorInitials: () => "TU", + getActorAvatarUrl: () => null, }), useWorkspaceStore: Object.assign( (selector?: any) => { diff --git a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx index d3ecb705..dbf40065 100644 --- a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { Save } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Camera, Loader2, Save } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -9,27 +9,48 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useAuthStore } from "@/features/auth"; import { api } from "@/shared/api"; +import { useFileUpload } from "@/hooks/use-file-upload"; export function AccountTab() { const user = useAuthStore((s) => s.user); const setUser = useAuthStore((s) => s.setUser); const [profileName, setProfileName] = useState(user?.name ?? ""); - const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? ""); const [profileSaving, setProfileSaving] = useState(false); + const { upload, uploading } = useFileUpload(); + const fileInputRef = useRef(null); useEffect(() => { setProfileName(user?.name ?? ""); - setAvatarUrl(user?.avatar_url ?? ""); }, [user]); + const initials = (user?.name ?? "") + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + 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 () => { setProfileSaving(true); try { - const updated = await api.updateMe({ - name: profileName, - avatar_url: avatarUrl || undefined, - }); + const updated = await api.updateMe({ name: profileName }); setUser(updated); toast.success("Profile updated"); } catch (e) { @@ -45,7 +66,46 @@ export function AccountTab() {

Profile

- + + {/* Avatar upload */} +
+ + +
+ Click to upload avatar +
+
+
-
- - setAvatarUrl(e.target.value)} - placeholder="https://example.com/avatar.png" - className="mt-1" - /> -
+
@@ -76,7 +101,23 @@ function ReplyInput({ }`} >
-
+
+ +
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index db3cd80f..f8fb097b 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -13,6 +13,7 @@ import { toast } from "sonner"; // --------------------------------------------------------------------------- interface ReplyInputProps { + issueId: string; placeholder?: string; avatarType: string; avatarId: string; @@ -25,6 +26,7 @@ interface ReplyInputProps { // --------------------------------------------------------------------------- function ReplyInput({ + issueId, placeholder = "Leave a reply...", avatarType, avatarId, @@ -39,7 +41,7 @@ function ReplyInput({ const handleUpload = async (file: File) => { try { - const result = await upload(file); + const result = await upload(file, { issueId }); return result; } catch (err) { toast.error(err instanceof Error ? err.message : "Upload failed"); diff --git a/apps/web/hooks/use-file-upload.ts b/apps/web/hooks/use-file-upload.ts index 812e6238..bd3071fb 100644 --- a/apps/web/hooks/use-file-upload.ts +++ b/apps/web/hooks/use-file-upload.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from "react"; import { api } from "@/shared/api"; +import type { Attachment } from "@/shared/types"; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB @@ -32,11 +33,16 @@ export interface UploadResult { link: string; } +export interface UploadContext { + issueId?: string; + commentId?: string; +} + export function useFileUpload() { const [uploading, setUploading] = useState(false); const upload = useCallback( - async (file: File): Promise => { + async (file: File, ctx?: UploadContext): Promise => { if (file.size > MAX_FILE_SIZE) { throw new Error("File exceeds 10 MB limit"); } @@ -46,7 +52,11 @@ export function useFileUpload() { setUploading(true); try { - return await api.uploadFile(file); + const att: Attachment = await api.uploadFile(file, { + issueId: ctx?.issueId, + commentId: ctx?.commentId, + }); + return { filename: att.filename, link: att.url }; } finally { setUploading(false); } diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 994cc666..172efc4b 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -35,6 +35,7 @@ import type { RuntimePing, TimelineEntry, TaskMessagePayload, + Attachment, } from "@/shared/types"; import { type Logger, noopLogger } from "@/shared/logger"; @@ -520,10 +521,12 @@ export class ApiClient { await this.fetch(`/api/tokens/${id}`, { method: "DELETE" }); } - // File Upload - async uploadFile(file: File): Promise<{ filename: string; link: string }> { + // File Upload & Attachments + async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise { 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 headers: Record = {}; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; @@ -556,6 +559,14 @@ export class ApiClient { throw new Error(message); } - return res.json() as Promise<{ filename: string; link: string }>; + return res.json() as Promise; + } + + async listAttachments(issueId: string): Promise { + return this.fetch(`/api/issues/${issueId}/attachments`); + } + + async deleteAttachment(id: string): Promise { + await this.fetch(`/api/attachments/${id}`, { method: "DELETE" }); } } diff --git a/apps/web/shared/types/attachment.ts b/apps/web/shared/types/attachment.ts new file mode 100644 index 00000000..c69ccc44 --- /dev/null +++ b/apps/web/shared/types/attachment.ts @@ -0,0 +1,13 @@ +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; + content_type: string; + size_bytes: number; + created_at: string; +} diff --git a/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 5ef60118..4c105ff5 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -30,3 +30,4 @@ export type { IssueSubscriber } from "./subscriber"; export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon"; export type * from "./events"; export type * from "./api"; +export type { Attachment } from "./attachment"; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index e65b6b9a..9b75fd5a 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -175,9 +175,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Get("/task-runs", h.ListTasksByIssue) r.Post("/reactions", h.AddIssueReaction) r.Delete("/reactions", h.RemoveIssueReaction) + r.Get("/attachments", h.ListAttachments) }) }) + // Attachments + r.Delete("/api/attachments/{id}", h.DeleteAttachment) + // Comments r.Route("/api/comments/{commentId}", func(r chi.Router) { r.Put("/", h.UpdateComment) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index 230c0336..c1afabc0 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -9,26 +9,29 @@ import ( "net/http" "path" "strings" + + "github.com/go-chi/chi/v5" + 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, + "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 { @@ -38,12 +41,64 @@ func isContentTypeAllowed(ct string) bool { 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"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` +} + +func 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, + ContentType: a.ContentType, + SizeBytes: a.SizeBytes, + CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), + } + if a.IssueID.Valid { + s := uuidToString(a.IssueID) + resp.IssueID = &s + } + if a.CommentID.Valid { + s := uuidToString(a.CommentID) + resp.CommentID = &s + } + return resp +} + +// --------------------------------------------------------------------------- +// 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 { @@ -98,8 +153,119 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { 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, 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] = 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) +} diff --git a/server/migrations/029_attachment.down.sql b/server/migrations/029_attachment.down.sql new file mode 100644 index 00000000..4e5f6d4f --- /dev/null +++ b/server/migrations/029_attachment.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS attachment; diff --git a/server/migrations/029_attachment.up.sql b/server/migrations/029_attachment.up.sql new file mode 100644 index 00000000..225c373a --- /dev/null +++ b/server/migrations/029_attachment.up.sql @@ -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); diff --git a/server/pkg/db/generated/attachment.sql.go b/server/pkg/db/generated/attachment.sql.go new file mode 100644 index 00000000..b653e2a9 --- /dev/null +++ b/server/pkg/db/generated/attachment.sql.go @@ -0,0 +1,188 @@ +// 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 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 +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 9547212e..9ba8f116 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -79,6 +79,20 @@ type AgentTaskQueue struct { 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 { ID pgtype.UUID `json:"id"` IssueID pgtype.UUID `json:"issue_id"` diff --git a/server/pkg/db/queries/attachment.sql b/server/pkg/db/queries/attachment.sql new file mode 100644 index 00000000..1505c2dd --- /dev/null +++ b/server/pkg/db/queries/attachment.sql @@ -0,0 +1,21 @@ +-- 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: DeleteAttachment :exec +DELETE FROM attachment WHERE id = $1 AND workspace_id = $2; From 37881adbede58e7dc316075e6828ed01e274240d Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:30:24 +0800 Subject: [PATCH 23/36] feat(server): trigger agents via @mention in comments When a user @mentions an agent in any issue's comment, the system now enqueues a task for that agent. The agent reads the issue context and replies to the triggering comment thread. Changes: - Add shared util.ParseMentions for mention parsing (used by both comment handler and notification listeners) - Add EnqueueTaskForMention to TaskService for explicit agent targeting - Add on_mention trigger type support in agent trigger config - Add HasPendingTaskForIssueAndAgent SQL query for per-agent dedup - Add enqueueMentionedAgentTasks in CreateComment handler Safety: prevents self-trigger (agent mentioning itself), dedup with assignee on_comment trigger, terminal issue status check, and per-agent pending task dedup. --- server/cmd/server/notification_listeners.go | 21 +++----- server/internal/handler/comment.go | 54 +++++++++++++++++++++ server/internal/handler/issue.go | 24 +++++++++ server/internal/service/task.go | 30 ++++++++++++ server/internal/util/mention.go | 28 +++++++++++ server/pkg/db/generated/agent.sql.go | 19 ++++++++ server/pkg/db/queries/agent.sql | 6 +++ 7 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 server/internal/util/mention.go diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index 0443b37a..1ad0ed92 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "log/slog" - "regexp" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" @@ -13,15 +12,12 @@ import ( "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 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-]+)\)`) - // statusLabels maps DB status values to human-readable labels for notifications. var statusLabels = map[string]string{ "backlog": "Backlog", @@ -59,17 +55,12 @@ func priorityLabel(p string) string { var emptyDetails = []byte("{}") // parseMentions extracts mentions from markdown content. +// Delegates to the shared util.ParseMentions and converts to the local type. 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]}) + parsed := util.ParseMentions(content) + result := make([]mention, len(parsed)) + for i, m := range parsed { + result[i] = mention{Type: m.Type, ID: m.ID} } return result } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index a1b2c38c..658a016b 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -1,6 +1,7 @@ package handler import ( + "context" "encoding/json" "log/slog" "net/http" @@ -8,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "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" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -158,9 +160,61 @@ 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) } +// 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) { commentId := chi.URLParam(r, "commentId") diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index bb24fcd7..ca3d9bd8 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -515,6 +515,30 @@ func (h *Handler) isAgentTriggerEnabled(ctx context.Context, issue db.Issue, tri 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) { id := chi.URLParam(r, "id") issue, ok := h.loadIssueForUser(w, r, id) diff --git a/server/internal/service/task.go b/server/internal/service/task.go index b2574aba..b09d56d3 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -69,6 +69,36 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t 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. func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UUID) error { return s.Queries.CancelAgentTasksByIssue(ctx, issueID) diff --git a/server/internal/util/mention.go b/server/internal/util/mention.go new file mode 100644 index 00000000..83249b2c --- /dev/null +++ b/server/internal/util/mention.go @@ -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 +} diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index f345e656..a951d44e 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -458,6 +458,25 @@ func (q *Queries) HasPendingTaskForIssue(ctx context.Context, issueID pgtype.UUI 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 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') diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index 83dd1a1b..2b581204 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -124,6 +124,12 @@ WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running'); SELECT count(*) > 0 AS has_pending FROM agent_task_queue 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 SELECT * FROM agent_task_queue WHERE runtime_id = $1 AND status IN ('queued', 'dispatched') From 35f77de9cc574db3285b4a5429bf8b3611e67416 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:30:51 +0800 Subject: [PATCH 24/36] Revert "Merge pull request #214 from multica-ai/agent/lambda/4771d426" This reverts commit cfd2fdf70fdecefd4f8465da849ba88701810d8d, reversing changes made to 987984431b76199305c7bd0e2ad17947434e2213. --- server/cmd/server/router.go | 36 +++---- server/internal/auth/jwt.go | 9 -- server/internal/handler/daemon_pairing.go | 40 +------ server/internal/middleware/daemon_auth.go | 112 -------------------- server/migrations/029_daemon_token.down.sql | 1 - server/migrations/029_daemon_token.up.sql | 11 -- server/pkg/db/generated/daemon_token.sql.go | 88 --------------- server/pkg/db/generated/models.go | 9 -- server/pkg/db/queries/daemon_token.sql | 16 --- 9 files changed, 16 insertions(+), 306 deletions(-) delete mode 100644 server/internal/middleware/daemon_auth.go delete mode 100644 server/migrations/029_daemon_token.down.sql delete mode 100644 server/migrations/029_daemon_token.up.sql delete mode 100644 server/pkg/db/generated/daemon_token.sql.go delete mode 100644 server/pkg/db/queries/daemon_token.sql diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 0f70001d..2de70b1e 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -79,34 +79,28 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Post("/auth/send-code", h.SendCode) r.Post("/auth/verify-code", h.VerifyCode) - // Daemon API routes + // Daemon API routes (no user auth; daemon auth deferred to later) r.Route("/api/daemon", func(r chi.Router) { - // Pairing routes — no auth required (daemon doesn't have a token yet). 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("/deregister", h.DaemonDeregister) + r.Post("/heartbeat", h.DaemonHeartbeat) - r.Post("/register", h.DaemonRegister) - r.Post("/deregister", h.DaemonDeregister) - r.Post("/heartbeat", h.DaemonHeartbeat) + r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) + r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime) + r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage) + r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult) - r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) - r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime) - r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage) - r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult) - - r.Get("/tasks/{taskId}/status", h.GetTaskStatus) - r.Post("/tasks/{taskId}/start", h.StartTask) - r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress) - r.Post("/tasks/{taskId}/complete", h.CompleteTask) - r.Post("/tasks/{taskId}/fail", h.FailTask) - r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages) - r.Get("/tasks/{taskId}/messages", h.ListTaskMessages) - }) + r.Get("/tasks/{taskId}/status", h.GetTaskStatus) + r.Post("/tasks/{taskId}/start", h.StartTask) + r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress) + r.Post("/tasks/{taskId}/complete", h.CompleteTask) + r.Post("/tasks/{taskId}/fail", h.FailTask) + r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages) + r.Get("/tasks/{taskId}/messages", h.ListTaskMessages) }) // Protected API routes diff --git a/server/internal/auth/jwt.go b/server/internal/auth/jwt.go index f300ed70..6ad212ff 100644 --- a/server/internal/auth/jwt.go +++ b/server/internal/auth/jwt.go @@ -37,15 +37,6 @@ func GeneratePATToken() (string, error) { return "mul_" + hex.EncodeToString(b), nil } -// GenerateDaemonToken creates a new daemon auth token: "mdt_" + 40 random hex chars. -func GenerateDaemonToken() (string, error) { - b := make([]byte, 20) // 20 bytes = 40 hex chars - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("generate daemon token: %w", err) - } - return "mdt_" + hex.EncodeToString(b), nil -} - // HashToken returns the hex-encoded SHA-256 hash of a token string. func HashToken(token string) string { h := sha256.Sum256([]byte(token)) diff --git a/server/internal/handler/daemon_pairing.go b/server/internal/handler/daemon_pairing.go index a8bfcfda..9cf7747f 100644 --- a/server/internal/handler/daemon_pairing.go +++ b/server/internal/handler/daemon_pairing.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "log/slog" "net/http" "net/url" "os" @@ -15,8 +14,6 @@ import ( "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 @@ -53,7 +50,6 @@ type DaemonPairingSessionResponse struct { 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 { @@ -386,39 +382,5 @@ func (h *Handler) ClaimDaemonPairingSession(w http.ResponseWriter, r *http.Reque 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) + writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) } diff --git a/server/internal/middleware/daemon_auth.go b/server/internal/middleware/daemon_auth.go deleted file mode 100644 index d91282cf..00000000 --- a/server/internal/middleware/daemon_auth.go +++ /dev/null @@ -1,112 +0,0 @@ -package middleware - -import ( - "context" - "log/slog" - "net/http" - "strings" - - "github.com/golang-jwt/jwt/v5" - "github.com/multica-ai/multica/server/internal/auth" - db "github.com/multica-ai/multica/server/pkg/db/generated" -) - -// Daemon context keys. -type daemonContextKey int - -const ( - ctxKeyDaemonWorkspaceID daemonContextKey = iota - ctxKeyDaemonID -) - -// DaemonWorkspaceIDFromContext returns the workspace ID set by DaemonAuth middleware. -func DaemonWorkspaceIDFromContext(ctx context.Context) string { - id, _ := ctx.Value(ctxKeyDaemonWorkspaceID).(string) - return id -} - -// DaemonIDFromContext returns the daemon ID set by DaemonAuth middleware. -func DaemonIDFromContext(ctx context.Context) string { - id, _ := ctx.Value(ctxKeyDaemonID).(string) - return id -} - -// DaemonAuth validates daemon auth tokens (mdt_ prefix) or falls back to -// JWT/PAT validation for backward compatibility with daemons that -// authenticate via user tokens. -func DaemonAuth(queries *db.Queries) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - slog.Debug("daemon_auth: missing authorization header", "path", r.URL.Path) - writeError(w, http.StatusUnauthorized, "missing authorization header") - return - } - - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - if tokenString == authHeader { - slog.Debug("daemon_auth: invalid format", "path", r.URL.Path) - writeError(w, http.StatusUnauthorized, "invalid authorization format") - return - } - - // Daemon token: "mdt_" prefix. - if strings.HasPrefix(tokenString, "mdt_") { - hash := auth.HashToken(tokenString) - dt, err := queries.GetDaemonTokenByHash(r.Context(), hash) - if err != nil { - slog.Warn("daemon_auth: invalid daemon token", "path", r.URL.Path, "error", err) - writeError(w, http.StatusUnauthorized, "invalid daemon token") - return - } - - ctx := context.WithValue(r.Context(), ctxKeyDaemonWorkspaceID, uuidToString(dt.WorkspaceID)) - ctx = context.WithValue(ctx, ctxKeyDaemonID, dt.DaemonID) - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - - // Fallback: PAT tokens ("mul_" prefix). - if strings.HasPrefix(tokenString, "mul_") { - hash := auth.HashToken(tokenString) - pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash) - if err != nil { - slog.Warn("daemon_auth: invalid PAT", "path", r.URL.Path, "error", err) - writeError(w, http.StatusUnauthorized, "invalid token") - return - } - r.Header.Set("X-User-ID", uuidToString(pat.UserID)) - go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID) - next.ServeHTTP(w, r) - return - } - - // Fallback: JWT tokens. - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, jwt.ErrSignatureInvalid - } - return auth.JWTSecret(), nil - }) - if err != nil || !token.Valid { - slog.Warn("daemon_auth: invalid token", "path", r.URL.Path, "error", err) - writeError(w, http.StatusUnauthorized, "invalid token") - return - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - writeError(w, http.StatusUnauthorized, "invalid claims") - return - } - sub, ok := claims["sub"].(string) - if !ok || strings.TrimSpace(sub) == "" { - writeError(w, http.StatusUnauthorized, "invalid claims") - return - } - r.Header.Set("X-User-ID", sub) - next.ServeHTTP(w, r) - }) - } -} diff --git a/server/migrations/029_daemon_token.down.sql b/server/migrations/029_daemon_token.down.sql deleted file mode 100644 index 18600acc..00000000 --- a/server/migrations/029_daemon_token.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS daemon_token; diff --git a/server/migrations/029_daemon_token.up.sql b/server/migrations/029_daemon_token.up.sql deleted file mode 100644 index 6704aa08..00000000 --- a/server/migrations/029_daemon_token.up.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE daemon_token ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token_hash TEXT NOT NULL, - workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, - daemon_id TEXT NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX idx_daemon_token_hash ON daemon_token(token_hash); -CREATE INDEX idx_daemon_token_workspace_daemon ON daemon_token(workspace_id, daemon_id); diff --git a/server/pkg/db/generated/daemon_token.sql.go b/server/pkg/db/generated/daemon_token.sql.go deleted file mode 100644 index 367d7504..00000000 --- a/server/pkg/db/generated/daemon_token.sql.go +++ /dev/null @@ -1,88 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: daemon_token.sql - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createDaemonToken = `-- name: CreateDaemonToken :one -INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at) -VALUES ($1, $2, $3, $4) -RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at -` - -type CreateDaemonTokenParams struct { - TokenHash string `json:"token_hash"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - DaemonID string `json:"daemon_id"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` -} - -func (q *Queries) CreateDaemonToken(ctx context.Context, arg CreateDaemonTokenParams) (DaemonToken, error) { - row := q.db.QueryRow(ctx, createDaemonToken, - arg.TokenHash, - arg.WorkspaceID, - arg.DaemonID, - arg.ExpiresAt, - ) - var i DaemonToken - err := row.Scan( - &i.ID, - &i.TokenHash, - &i.WorkspaceID, - &i.DaemonID, - &i.ExpiresAt, - &i.CreatedAt, - ) - return i, err -} - -const deleteDaemonTokensByWorkspaceAndDaemon = `-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec -DELETE FROM daemon_token -WHERE workspace_id = $1 AND daemon_id = $2 -` - -type DeleteDaemonTokensByWorkspaceAndDaemonParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - DaemonID string `json:"daemon_id"` -} - -func (q *Queries) DeleteDaemonTokensByWorkspaceAndDaemon(ctx context.Context, arg DeleteDaemonTokensByWorkspaceAndDaemonParams) error { - _, err := q.db.Exec(ctx, deleteDaemonTokensByWorkspaceAndDaemon, arg.WorkspaceID, arg.DaemonID) - return err -} - -const deleteExpiredDaemonTokens = `-- name: DeleteExpiredDaemonTokens :exec -DELETE FROM daemon_token -WHERE expires_at <= now() -` - -func (q *Queries) DeleteExpiredDaemonTokens(ctx context.Context) error { - _, err := q.db.Exec(ctx, deleteExpiredDaemonTokens) - return err -} - -const getDaemonTokenByHash = `-- name: GetDaemonTokenByHash :one -SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at FROM daemon_token -WHERE token_hash = $1 AND expires_at > now() -` - -func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (DaemonToken, error) { - row := q.db.QueryRow(ctx, getDaemonTokenByHash, tokenHash) - var i DaemonToken - err := row.Scan( - &i.ID, - &i.TokenHash, - &i.WorkspaceID, - &i.DaemonID, - &i.ExpiresAt, - &i.CreatedAt, - ) - return i, err -} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 78a736c5..9547212e 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -131,15 +131,6 @@ type DaemonPairingSession struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type DaemonToken struct { - ID pgtype.UUID `json:"id"` - TokenHash string `json:"token_hash"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - DaemonID string `json:"daemon_id"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - type InboxItem struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` diff --git a/server/pkg/db/queries/daemon_token.sql b/server/pkg/db/queries/daemon_token.sql deleted file mode 100644 index 252b17f2..00000000 --- a/server/pkg/db/queries/daemon_token.sql +++ /dev/null @@ -1,16 +0,0 @@ --- name: CreateDaemonToken :one -INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at) -VALUES ($1, $2, $3, $4) -RETURNING *; - --- name: GetDaemonTokenByHash :one -SELECT * FROM daemon_token -WHERE token_hash = $1 AND expires_at > now(); - --- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec -DELETE FROM daemon_token -WHERE workspace_id = $1 AND daemon_id = $2; - --- name: DeleteExpiredDaemonTokens :exec -DELETE FROM daemon_token -WHERE expires_at <= now(); From ac2a4c419f481e96d205eb2863d8545ec7094349 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:38:29 +0800 Subject: [PATCH 25/36] refactor(editor): migrate to @tiptap/markdown, fix mention rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace tiptap-markdown with official @tiptap/markdown (markdown→JSON direct, skip DOM) - Add contentType:"markdown" for proper \n\n paragraph parsing - Fix mention renderHTML: use mergeAttributes for class/data-type, - Fix type attribute leak: add renderHTML:()=>({}) to suppress raw "type" attr - Link style: permanent underline → hover-only underline (matches read-only) - Mention style: primary+background pill → brand color text only - Comment edit: replace with RichTextEditor for consistency Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/common/rich-text-editor.css | 10 +- .../components/common/rich-text-editor.tsx | 109 ++++++++---------- apps/web/components/markdown/Markdown.tsx | 5 +- .../issues/components/comment-card.tsx | 42 +++---- 4 files changed, 74 insertions(+), 92 deletions(-) diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 47c3c416..4df15d5d 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -127,18 +127,20 @@ /* Links */ .rich-text-editor a { color: var(--primary); + text-decoration: none; +} + +.rich-text-editor a:hover { text-decoration: underline; text-underline-offset: 2px; } /* Mentions */ .rich-text-editor .mention { - color: var(--primary); - background: color-mix(in srgb, var(--primary) 8%, transparent); - padding: 0 0.2em; - border-radius: calc(var(--radius) * 0.5); + color: var(--brand); font-weight: 500; text-decoration: none; + margin: 0 0.125rem; } /* Strong / emphasis */ diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 447c4278..dd1c148a 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -13,7 +13,7 @@ import Link from "@tiptap/extension-link"; import Typography from "@tiptap/extension-typography"; import Mention from "@tiptap/extension-mention"; import { Markdown } from "@tiptap/markdown"; -import { Extension } from "@tiptap/core"; +import { Extension, mergeAttributes } from "@tiptap/core"; import { cn } from "@/lib/utils"; import { createMentionSuggestion } from "./mention-suggestion"; import "./rich-text-editor.css"; @@ -38,48 +38,12 @@ interface RichTextEditorRef { focus: () => 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 autolinks; -// support Cmd+Click / Ctrl+Click to open in new tab. -// --------------------------------------------------------------------------- - const LinkExtension = Link.configure({ openOnClick: true, autolink: true, HTMLAttributes: { 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({ @@ -88,13 +52,16 @@ const MentionExtension = Mention.configure({ }).extend({ renderHTML({ node, HTMLAttributes }) { return [ - "a", - { - ...HTMLAttributes, - href: `mention://${node.attrs.type ?? "member"}/${node.attrs.id}`, - "data-mention-type": node.attrs.type ?? "member", - "data-mention-id": node.attrs.id, - }, + "span", + mergeAttributes( + { "data-type": "mention" }, + this.options.HTMLAttributes, + HTMLAttributes, + { + "data-mention-type": node.attrs.type ?? "member", + "data-mention-id": node.attrs.id, + }, + ), `@${node.attrs.label ?? node.attrs.id}`, ]; }, @@ -103,21 +70,39 @@ const MentionExtension = Mention.configure({ ...this.parent?.(), type: { default: "member", - parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member", + parseHTML: (el: HTMLElement) => + el.getAttribute("data-mention-type") ?? "member", + renderHTML: () => ({}), }, }; }, - addStorage() { - return { - markdown: { - serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) { - state.write( - `[@${node.attrs.label ?? node.attrs.id}](mention://${node.attrs.type ?? "member"}/${node.attrs.id})`, - ); - }, - parse: {}, - }, - }; + // @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 + parseMarkdown: (token: any, helpers: any) => { + return helpers.createNode("mention", token.attributes); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderMarkdown: (node: any) => { + const { id, label, type = "member" } = node.attrs || {}; + return `[@${label ?? id}](mention://${type}/${id})`; }, }); @@ -160,11 +145,6 @@ const RichTextEditor = forwardRef( const onUpdateRef = useRef(onUpdate); const onSubmitRef = useRef(onSubmit); - // Helper to get markdown from tiptap-markdown storage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getEditorMarkdown = (ed: any): string => - ed?.storage?.markdown?.getMarkdown?.() ?? ""; - // Keep refs in sync without recreating editor onUpdateRef.current = onUpdate; onSubmitRef.current = onSubmit; @@ -172,7 +152,8 @@ const RichTextEditor = forwardRef( const editor = useEditor({ immediatelyRender: false, editable, - content: defaultValue, + content: defaultValue || "", + contentType: defaultValue ? "markdown" : undefined, extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, @@ -191,7 +172,7 @@ const RichTextEditor = forwardRef( if (!onUpdateRef.current) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - onUpdateRef.current?.(getEditorMarkdown(ed)); + onUpdateRef.current?.(ed.getMarkdown()); }, debounceMs); }, editorProps: { @@ -223,7 +204,7 @@ const RichTextEditor = forwardRef( }, []); useImperativeHandle(ref, () => ({ - getMarkdown: () => getEditorMarkdown(editor), + getMarkdown: () => editor?.getMarkdown() ?? "", clearContent: () => { editor?.commands.clearContent(); }, diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 511084da..a63c6213 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -61,10 +61,7 @@ function createComponents( // Mention links: mention://member/id or mention://agent/id if (href?.startsWith('mention://')) { return ( - + {children} ) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index b72baec2..13aed067 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Card } from "@/components/ui/card"; @@ -18,6 +18,7 @@ import { ReactionBar } from "@/components/common/reaction-bar"; import { Markdown } from "@/components/markdown"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; +import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { ReplyInput } from "./reply-input"; import type { TimelineEntry } from "@/shared/types"; @@ -54,28 +55,28 @@ function CommentRow({ }) { const { getActorName } = useActorName(); const [editing, setEditing] = useState(false); - const [editContent, setEditContent] = useState(""); + const editEditorRef = useRef(null); const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; const isTemp = entry.id.startsWith("temp-"); const startEdit = () => { - setEditContent(entry.content ?? ""); setEditing(true); }; const cancelEdit = () => { setEditing(false); - setEditContent(""); }; const saveEdit = async () => { - const trimmed = editContent.trim(); + const trimmed = editEditorRef.current + ?.getMarkdown() + ?.replace(/(\n\s*)+$/, "") + .trim(); if (!trimmed) return; try { await onEdit(entry.id, trimmed); setEditing(false); - setEditContent(""); } catch { toast.error("Failed to update comment"); } @@ -140,23 +141,24 @@ function CommentRow({
{editing ? ( -
{ e.preventDefault(); saveEdit(); }} +
{ if (e.key === "Escape") cancelEdit(); }} > - 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(); }} - /> -
- - +
+
- +
+ + +
+
) : ( <>
From 9f03b73809176153593b751ecf5ead138d3e2a94 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:38:42 +0800 Subject: [PATCH 26/36] feat(editor): add TitleEditor component, replace for issue titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New TitleEditor: minimal tiptap (Document+Paragraph+Text+Placeholder) - Single-paragraph constraint prevents Enter from creating new lines - contenteditable div enables visual word-wrap (no horizontal scroll) - Enter→submit+blur, Shift+Enter blocked, Escape→blur - Replace in create-issue modal and in issue-detail - Remove titleDraft state/titleFocusedRef/sync effect from issue-detail - Fix duplicate React key: TitleEditor key={`title-${id}`} Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/globals.css | 8 +- apps/web/components/common/title-editor.css | 18 +++ apps/web/components/common/title-editor.tsx | 141 ++++++++++++++++++ .../issues/components/issue-detail.tsx | 38 ++--- apps/web/features/modals/create-issue.tsx | 18 +-- 5 files changed, 178 insertions(+), 45 deletions(-) create mode 100644 apps/web/components/common/title-editor.css create mode 100644 apps/web/components/common/title-editor.tsx diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 66118975..ab982eeb 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -96,8 +96,8 @@ --warning: oklch(0.75 0.16 85); --info: oklch(0.55 0.18 250); --priority: oklch(0.65 0.18 50); - --scrollbar-thumb: oklch(0.82 0.003 286); - --scrollbar-thumb-hover: oklch(0.705 0.015 286.067); + --scrollbar-thumb: oklch(0 0 0 / 10%); + --scrollbar-thumb-hover: oklch(0 0 0 / 18%); --scrollbar-track: transparent; } @@ -140,8 +140,8 @@ --warning: oklch(0.70 0.16 85); --info: oklch(0.65 0.18 250); --priority: oklch(0.70 0.18 50); - --scrollbar-thumb: oklch(1 0 0 / 15%); - --scrollbar-thumb-hover: oklch(1 0 0 / 30%); + --scrollbar-thumb: oklch(1 0 0 / 8%); + --scrollbar-thumb-hover: oklch(1 0 0 / 18%); --scrollbar-track: transparent; } diff --git a/apps/web/components/common/title-editor.css b/apps/web/components/common/title-editor.css new file mode 100644 index 00000000..70d63a06 --- /dev/null +++ b/apps/web/components/common/title-editor.css @@ -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; +} diff --git a/apps/web/components/common/title-editor.tsx b/apps/web/components/common/title-editor.tsx new file mode 100644 index 00000000..14837b27 --- /dev/null +++ b/apps/web/components/common/title-editor.tsx @@ -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( + 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: `

${defaultValue}

`, + 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 ; + }, +); + +export { TitleEditor, type TitleEditorProps, type TitleEditorRef }; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 53e44bb9..c6f8db86 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -1,6 +1,6 @@ "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 Link from "next/link"; import { useRouter } from "next/navigation"; @@ -43,8 +43,8 @@ import { DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; -import { Input } from "@/components/ui/input"; import { RichTextEditor } from "@/components/common/rich-text-editor"; +import { TitleEditor } from "@/components/common/title-editor"; import { Tooltip, TooltipTrigger, @@ -185,8 +185,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); const [deleting, setDeleting] = useState(false); - const [titleDraft, setTitleDraft] = useState(""); - const titleFocusedRef = useRef(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); @@ -211,13 +209,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo .finally(() => setIssueLoading(false)); }, [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 const { timeline, submitting, submitComment, submitReply, @@ -547,26 +538,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {/* Content — scrollable */}
- setTitleDraft(e.target.value)} - onFocus={() => { titleFocusedRef.current = true; }} - onBlur={() => { - titleFocusedRef.current = false; - const trimmed = titleDraft.trim(); + { + const trimmed = value.trim(); 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" /> void; data? {/* Title */}
- updateTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }} + defaultValue={draft.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} />
From f5353c66917e2a329b78f76354895280ba78e99f Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 15:42:10 +0800 Subject: [PATCH 27/36] feat(upload): signed URLs for CLI + eager load attachments on comments - Add CloudFrontSigner.SignedURL() for generating per-resource signed URLs - Attachment responses include download_url (5-min signed URL for CLI) - Eager load attachments on comments and timeline (same pattern as reactions) - Add ListAttachmentsByCommentIDs query for batch loading - Update Comment and TimelineEntry types with attachments field Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/issues/[id]/page.test.tsx | 1 + apps/web/shared/types/activity.ts | 2 + apps/web/shared/types/attachment.ts | 1 + apps/web/shared/types/comment.ts | 1 + server/internal/auth/cloudfront.go | 23 ++++++++ server/internal/handler/activity.go | 20 ++++--- server/internal/handler/comment.go | 59 +++++++++++-------- server/internal/handler/file.go | 30 +++++++++- server/pkg/db/generated/attachment.sql.go | 38 ++++++++++++ server/pkg/db/queries/attachment.sql | 5 ++ 10 files changed, 144 insertions(+), 36 deletions(-) diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 77739728..065d8051 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -297,6 +297,7 @@ describe("IssueDetailPage", () => { author_id: "user-1", parent_id: null, reactions: [], + attachments: [], created_at: "2026-01-18T00:00:00Z", updated_at: "2026-01-18T00:00:00Z", }; diff --git a/apps/web/shared/types/activity.ts b/apps/web/shared/types/activity.ts index 5dc2e9fa..d14cbebc 100644 --- a/apps/web/shared/types/activity.ts +++ b/apps/web/shared/types/activity.ts @@ -1,4 +1,5 @@ import type { Reaction } from "./comment"; +import type { Attachment } from "./attachment"; export interface TimelineEntry { type: "activity" | "comment"; @@ -15,4 +16,5 @@ export interface TimelineEntry { updated_at?: string; comment_type?: string; reactions?: Reaction[]; + attachments?: Attachment[]; } diff --git a/apps/web/shared/types/attachment.ts b/apps/web/shared/types/attachment.ts index c69ccc44..9908850c 100644 --- a/apps/web/shared/types/attachment.ts +++ b/apps/web/shared/types/attachment.ts @@ -7,6 +7,7 @@ export interface Attachment { uploader_id: string; filename: string; url: string; + download_url: string; content_type: string; size_bytes: number; created_at: string; diff --git a/apps/web/shared/types/comment.ts b/apps/web/shared/types/comment.ts index bd2a4b57..c06c4f04 100644 --- a/apps/web/shared/types/comment.ts +++ b/apps/web/shared/types/comment.ts @@ -20,6 +20,7 @@ export interface Comment { type: CommentType; parent_id: string | null; reactions: Reaction[]; + attachments: import("./attachment").Attachment[]; created_at: string; updated_at: string; } diff --git a/server/internal/auth/cloudfront.go b/server/internal/auth/cloudfront.go index ee255378..e4cccd6d 100644 --- a/server/internal/auth/cloudfront.go +++ b/server/internal/auth/cloudfront.go @@ -172,6 +172,29 @@ func (s *CloudFrontSigner) SignedCookies(expiry time.Time) []*http.Cookie { } } +// 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) diff --git a/server/internal/handler/activity.go b/server/internal/handler/activity.go index 5430b78a..b73810eb 100644 --- a/server/internal/handler/activity.go +++ b/server/internal/handler/activity.go @@ -25,11 +25,12 @@ type TimelineEntry struct { Details json.RawMessage `json:"details,omitempty"` // Comment-only fields - Content *string `json:"content,omitempty"` - ParentID *string `json:"parent_id,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - CommentType *string `json:"comment_type,omitempty"` - Reactions []ReactionResponse `json:"reactions,omitempty"` + Content *string `json:"content,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + CommentType *string `json:"comment_type,omitempty"` + Reactions []ReactionResponse `json:"reactions,omitempty"` + Attachments []AttachmentResponse `json:"attachments,omitempty"` } // 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)) for i, c := range comments { commentIDs[i] = c.ID } grouped := h.groupReactions(r, commentIDs) + groupedAtt := h.groupAttachments(r, commentIDs) for _, c := range comments { content := c.Content commentType := c.Type updatedAt := timestampToString(c.UpdatedAt) + cid := uuidToString(c.ID) timeline = append(timeline, TimelineEntry{ Type: "comment", - ID: uuidToString(c.ID), + ID: cid, ActorType: c.AuthorType, ActorID: uuidToString(c.AuthorID), Content: &content, @@ -100,7 +103,8 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { ParentID: uuidToPtr(c.ParentID), CreatedAt: timestampToString(c.CreatedAt), UpdatedAt: &updatedAt, - Reactions: grouped[uuidToString(c.ID)], + Reactions: grouped[cid], + Attachments: groupedAtt[cid], }) } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index a1b2c38c..f00f15c2 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -13,33 +13,38 @@ import ( ) type CommentResponse struct { - ID string `json:"id"` - IssueID string `json:"issue_id"` - AuthorType string `json:"author_type"` - AuthorID string `json:"author_id"` - Content string `json:"content"` - Type string `json:"type"` - ParentID *string `json:"parent_id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Reactions []ReactionResponse `json:"reactions"` + ID string `json:"id"` + IssueID string `json:"issue_id"` + AuthorType string `json:"author_type"` + AuthorID string `json:"author_id"` + Content string `json:"content"` + Type string `json:"type"` + ParentID *string `json:"parent_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + 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 { reactions = []ReactionResponse{} } + if attachments == nil { + attachments = []AttachmentResponse{} + } return CommentResponse{ - ID: uuidToString(c.ID), - IssueID: uuidToString(c.IssueID), - AuthorType: c.AuthorType, - AuthorID: uuidToString(c.AuthorID), - Content: c.Content, - Type: c.Type, - ParentID: uuidToPtr(c.ParentID), - CreatedAt: timestampToString(c.CreatedAt), - UpdatedAt: timestampToString(c.UpdatedAt), - Reactions: reactions, + ID: uuidToString(c.ID), + IssueID: uuidToString(c.IssueID), + AuthorType: c.AuthorType, + AuthorID: uuidToString(c.AuthorID), + Content: c.Content, + Type: c.Type, + ParentID: uuidToPtr(c.ParentID), + CreatedAt: timestampToString(c.CreatedAt), + UpdatedAt: timestampToString(c.UpdatedAt), + Reactions: reactions, + Attachments: attachments, } } @@ -64,10 +69,12 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) { commentIDs[i] = c.ID } grouped := h.groupReactions(r, commentIDs) + groupedAtt := h.groupAttachments(r, commentIDs) resp := make([]CommentResponse, len(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) @@ -133,7 +140,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { 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)...) h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{ "comment": resp, @@ -215,9 +222,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { return } - // Fetch reactions for the updated comment. + // Fetch reactions and attachments for the updated comment. 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)...) h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp}) writeJSON(w, http.StatusOK, resp) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index c1afabc0..04c70649 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -9,8 +9,10 @@ import ( "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" ) @@ -54,12 +56,13 @@ type AttachmentResponse struct { 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 attachmentToResponse(a db.Attachment) AttachmentResponse { +func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse { resp := AttachmentResponse{ ID: uuidToString(a.ID), WorkspaceID: uuidToString(a.WorkspaceID), @@ -67,10 +70,14 @@ func attachmentToResponse(a db.Attachment) AttachmentResponse { 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 @@ -82,6 +89,23 @@ func attachmentToResponse(a db.Attachment) AttachmentResponse { 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 { + 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 // --------------------------------------------------------------------------- @@ -181,7 +205,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { // 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, attachmentToResponse(att)) + writeJSON(w, http.StatusOK, h.attachmentToResponse(att)) return } } @@ -216,7 +240,7 @@ func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) { resp := make([]AttachmentResponse, len(attachments)) for i, a := range attachments { - resp[i] = attachmentToResponse(a) + resp[i] = h.attachmentToResponse(a) } writeJSON(w, http.StatusOK, resp) } diff --git a/server/pkg/db/generated/attachment.sql.go b/server/pkg/db/generated/attachment.sql.go index b653e2a9..858365ad 100644 --- a/server/pkg/db/generated/attachment.sql.go +++ b/server/pkg/db/generated/attachment.sql.go @@ -144,6 +144,44 @@ func (q *Queries) ListAttachmentsByComment(ctx context.Context, arg ListAttachme 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 diff --git a/server/pkg/db/queries/attachment.sql b/server/pkg/db/queries/attachment.sql index 1505c2dd..6003ab88 100644 --- a/server/pkg/db/queries/attachment.sql +++ b/server/pkg/db/queries/attachment.sql @@ -17,5 +17,10 @@ ORDER BY created_at ASC; 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; From 7aea32cb33bc9ac17c727a7e6f7f28c591f0dee0 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:42:54 +0800 Subject: [PATCH 28/36] fix(server): suppress assignee on_comment when mentions target others MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a comment @mentions anyone but not the assignee agent, the assignee's on_comment trigger is now suppressed. This prevents the assignee agent from being re-triggered when users share results with colleagues or ask other agents for help. The rule: @mention is an intent signal — if you're talking to someone else, the assignee agent should not respond. --- server/internal/handler/comment.go | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 658a016b..c3f9ebb4 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -147,7 +147,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. // 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 // to the thread root (matching frontend behavior where all replies // in a thread share the same top-level parent). @@ -166,6 +169,27 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { 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 From feb5f576622de6a0e6f3bce69378bc280102d3a8 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:47:09 +0800 Subject: [PATCH 29/36] =?UTF-8?q?fix:=20merge=20main=20and=20renumber=20mi?= =?UTF-8?q?gration=20028=20=E2=86=92=20030?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main now has 028 (task_trigger_comment) and 029 (daemon_token). Renumber agent_default_private migration to 030 to avoid conflict. --- ...efault_private.down.sql => 030_agent_default_private.down.sql} | 0 ...nt_default_private.up.sql => 030_agent_default_private.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename server/migrations/{028_agent_default_private.down.sql => 030_agent_default_private.down.sql} (100%) rename server/migrations/{028_agent_default_private.up.sql => 030_agent_default_private.up.sql} (100%) diff --git a/server/migrations/028_agent_default_private.down.sql b/server/migrations/030_agent_default_private.down.sql similarity index 100% rename from server/migrations/028_agent_default_private.down.sql rename to server/migrations/030_agent_default_private.down.sql diff --git a/server/migrations/028_agent_default_private.up.sql b/server/migrations/030_agent_default_private.up.sql similarity index 100% rename from server/migrations/028_agent_default_private.up.sql rename to server/migrations/030_agent_default_private.up.sql From b9ea10c89dfaf285534abbbd00ef1133005c7cf8 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:50:22 +0800 Subject: [PATCH 30/36] fix(comments): unify rendering with RichTextEditor, fix mention/link colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Comment display: replace with - Link color: primary → brand (blue) - Mention color: brand → primary + semibold - Add MentionHoverCard component with HoverCardTrigger render={} - Markdown.tsx: sync mention style to text-primary font-semibold Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/common/mention-hover-card.tsx | 70 +++++++++++++++++++ .../components/common/rich-text-editor.css | 6 +- apps/web/components/markdown/Markdown.tsx | 12 +++- .../issues/components/comment-card.tsx | 3 +- 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 apps/web/components/common/mention-hover-card.tsx diff --git a/apps/web/components/common/mention-hover-card.tsx b/apps/web/components/common/mention-hover-card.tsx new file mode 100644 index 00000000..f54d4ad2 --- /dev/null +++ b/apps/web/components/common/mention-hover-card.tsx @@ -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 ( + + } className="cursor-default"> + {children} + + +
+ +
+

{member.name}

+

{member.email}

+
+
+
+
+ ); + } + + if (type === "agent") { + const agent = agents.find((a) => a.id === id); + if (!agent) return <>{children}; + + return ( + + } className="cursor-default"> + {children} + + +
+
+ +
+
+

{agent.name}

+ {agent.description && ( +

{agent.description}

+ )} +
+
+
+
+ ); + } + + return <>{children}; +} + +export { MentionHoverCard }; diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 4df15d5d..f387d05d 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -126,7 +126,7 @@ /* Links */ .rich-text-editor a { - color: var(--primary); + color: var(--brand); text-decoration: none; } @@ -137,8 +137,8 @@ /* Mentions */ .rich-text-editor .mention { - color: var(--brand); - font-weight: 500; + color: var(--primary); + font-weight: 600; text-decoration: none; margin: 0 0.125rem; } diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index a63c6213..00038dad 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/utils' +import { MentionHoverCard } from '@/components/common/mention-hover-card' import { CodeBlock, InlineCode } from './CodeBlock' import { preprocessLinks } from './linkify' @@ -60,10 +61,15 @@ function createComponents( a: ({ href, children }) => { // Mention links: mention://member/id or mention://agent/id if (href?.startsWith('mention://')) { + const parts = href.replace('mention://', '').split('/') + const mentionType = parts[0] ?? 'member' + const mentionId = parts[1] ?? '' return ( - - {children} - + + + {children} + + ) } diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 13aed067..2e2b7b51 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -15,7 +15,6 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { ReactionBar } from "@/components/common/reaction-bar"; -import { Markdown } from "@/components/markdown"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; @@ -162,7 +161,7 @@ function CommentRow({ ) : ( <>
- {entry.content ?? ""} +
{!isTemp && ( Date: Tue, 31 Mar 2026 15:52:40 +0800 Subject: [PATCH 31/36] =?UTF-8?q?fix(upload):=20harden=20upload=20flow=20?= =?UTF-8?q?=E2=80=94=20sanitize=20filenames,=20refresh=20CF=20cookies,=20d?= =?UTF-8?q?eduplicate=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sanitize Content-Disposition filenames to prevent header injection (strip control chars, quotes, semicolons) - Add CloudFront cookie refresh middleware so cookies are re-issued when expired - Log errors in groupAttachments instead of silently swallowing them - Move useFileUpload hook to shared/hooks/ per project architecture conventions - Add uploadWithToast helper to deduplicate try/catch/toast pattern across 3 components - Refactor ApiClient.uploadFile to reuse auth headers, 401 handling, and error parsing - Allow empty MIME types client-side (let server sniff and decide) - Constrain Image extension max-width in rich-text-editor to prevent layout overflow Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/_components/account-tab.tsx | 2 +- .../components/common/rich-text-editor.tsx | 8 +- .../issues/components/comment-input.tsx | 15 +--- .../issues/components/issue-detail.tsx | 15 +--- .../issues/components/reply-input.tsx | 15 +--- apps/web/shared/api/client.ts | 86 +++++++++---------- .../web/{ => shared}/hooks/use-file-upload.ts | 17 +++- server/cmd/server/router.go | 1 + server/internal/handler/file.go | 1 + server/internal/middleware/cloudfront.go | 28 ++++++ server/internal/storage/s3.go | 18 +++- 11 files changed, 120 insertions(+), 86 deletions(-) rename apps/web/{ => shared}/hooks/use-file-upload.ts (74%) create mode 100644 server/internal/middleware/cloudfront.go diff --git a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx index dbf40065..78f3524e 100644 --- a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx @@ -9,7 +9,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useAuthStore } from "@/features/auth"; import { api } from "@/shared/api"; -import { useFileUpload } from "@/hooks/use-file-upload"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; export function AccountTab() { const user = useAuthStore((s) => s.user); diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 5d365649..058ece36 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -17,7 +17,7 @@ import { Markdown } from "tiptap-markdown"; import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { cn } from "@/lib/utils"; -import type { UploadResult } from "@/hooks/use-file-upload"; +import type { UploadResult } from "@/shared/hooks/use-file-upload"; import { createMentionSuggestion } from "./mention-suggestion"; import "./rich-text-editor.css"; @@ -263,7 +263,11 @@ const RichTextEditor = forwardRef( LinkExtension, Typography, MentionExtension, - Image.configure({ inline: false, allowBase64: false }), + Image.configure({ + inline: false, + allowBase64: false, + HTMLAttributes: { style: "max-width: 100%; height: auto;" }, + }), Markdown.configure({ html: false, transformPastedText: true, diff --git a/apps/web/features/issues/components/comment-input.tsx b/apps/web/features/issues/components/comment-input.tsx index 34619713..e889565f 100644 --- a/apps/web/features/issues/components/comment-input.tsx +++ b/apps/web/features/issues/components/comment-input.tsx @@ -4,8 +4,7 @@ import { useRef, useState } from "react"; import { ArrowUp, Paperclip } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; -import { useFileUpload } from "@/hooks/use-file-upload"; -import { toast } from "sonner"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; interface CommentInputProps { issueId: string; @@ -17,17 +16,9 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) { const fileInputRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); - const { upload, uploading } = useFileUpload(); + const { uploadWithToast, uploading } = useFileUpload(); - const handleUpload = async (file: File) => { - try { - const result = await upload(file, { issueId }); - return result; - } catch (err) { - toast.error(err instanceof Error ? err.message : "Upload failed"); - return null; - } - }; + const handleUpload = (file: File) => uploadWithToast(file, { issueId }); const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index bc0d2a0a..fc3f2a09 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -69,7 +69,7 @@ import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline"; import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions"; import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers"; import { ReactionBar } from "@/components/common/reaction-bar"; -import { useFileUpload } from "@/hooks/use-file-upload"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { timeAgo } from "@/shared/utils"; function shortDate(date: string | null): string { @@ -180,7 +180,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null; const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null; const { getActorName, getActorInitials } = useActorName(); - const { upload: uploadFile } = useFileUpload(); + const { uploadWithToast } = useFileUpload(); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: layoutId, }); @@ -252,15 +252,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo ); const handleDescriptionUpload = useCallback( - async (file: File) => { - try { - return await uploadFile(file, { issueId: id }); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Upload failed"); - return null; - } - }, - [uploadFile, id], + (file: File) => uploadWithToast(file, { issueId: id }), + [uploadWithToast, id], ); const handleDelete = async () => { diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index f8fb097b..0d61955f 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -5,8 +5,7 @@ import { ArrowUp, Paperclip } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { ActorAvatar } from "@/components/common/actor-avatar"; -import { useFileUpload } from "@/hooks/use-file-upload"; -import { toast } from "sonner"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; // --------------------------------------------------------------------------- // Types @@ -37,17 +36,9 @@ function ReplyInput({ const fileInputRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); - const { upload, uploading } = useFileUpload(); + const { uploadWithToast, uploading } = useFileUpload(); - const handleUpload = async (file: File) => { - try { - const result = await upload(file, { issueId }); - return result; - } catch (err) { - toast.error(err instanceof Error ? err.message : "Upload failed"); - return null; - } - }; + const handleUpload = (file: File) => uploadWithToast(file, { issueId }); const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 172efc4b..f6a080b0 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -63,6 +63,35 @@ export class ApiClient { this.workspaceId = id; } + private authHeaders(): Record { + const headers: Record = {}; + if (this.token) headers["Authorization"] = `Bearer ${this.token}`; + if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId; + return headers; + } + + private handleUnauthorized() { + if (typeof window !== "undefined") { + localStorage.removeItem("multica_token"); + localStorage.removeItem("multica_workspace_id"); + this.token = null; + this.workspaceId = null; + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + } + } + + private async parseErrorMessage(res: Response, fallback: string): Promise { + try { + const data = await res.json() as { error?: string }; + if (typeof data.error === "string" && data.error) return data.error; + } catch { + // Ignore non-JSON error bodies. + } + return fallback; + } + private async fetch(path: string, init?: RequestInit): Promise { const rid = crypto.randomUUID().slice(0, 8); const start = Date.now(); @@ -71,14 +100,9 @@ export class ApiClient { const headers: Record = { "Content-Type": "application/json", "X-Request-ID": rid, + ...this.authHeaders(), ...((init?.headers as Record) ?? {}), }; - if (this.token) { - headers["Authorization"] = `Bearer ${this.token}`; - } - if (this.workspaceId) { - headers["X-Workspace-ID"] = this.workspaceId; - } this.logger.info(`→ ${method} ${path}`, { rid }); @@ -88,25 +112,8 @@ export class ApiClient { }); if (!res.ok) { - if (res.status === 401 && typeof window !== "undefined") { - localStorage.removeItem("multica_token"); - localStorage.removeItem("multica_workspace_id"); - this.token = null; - this.workspaceId = null; - if (window.location.pathname !== "/login") { - window.location.href = "/login"; - } - } - - let message = `API error: ${res.status} ${res.statusText}`; - try { - const data = await res.json() as { error?: string }; - if (typeof data.error === "string" && data.error) { - message = data.error; - } - } catch { - // Ignore non-JSON error bodies. - } + 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 }); throw new Error(message); } @@ -528,37 +535,24 @@ export class ApiClient { if (opts?.issueId) formData.append("issue_id", opts.issueId); if (opts?.commentId) formData.append("comment_id", opts.commentId); - const headers: Record = {}; - if (this.token) headers["Authorization"] = `Bearer ${this.token}`; - if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId; + 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, + headers: this.authHeaders(), body: formData, }); if (!res.ok) { - if (res.status === 401 && typeof window !== "undefined") { - localStorage.removeItem("multica_token"); - localStorage.removeItem("multica_workspace_id"); - this.token = null; - this.workspaceId = null; - if (window.location.pathname !== "/login") { - window.location.href = "/login"; - } - } - - let message = `Upload failed: ${res.status}`; - try { - const data = (await res.json()) as { error?: string }; - if (typeof data.error === "string" && data.error) message = data.error; - } catch { - // Ignore non-JSON error bodies. - } + 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; } diff --git a/apps/web/hooks/use-file-upload.ts b/apps/web/shared/hooks/use-file-upload.ts similarity index 74% rename from apps/web/hooks/use-file-upload.ts rename to apps/web/shared/hooks/use-file-upload.ts index bd3071fb..ef5bafbe 100644 --- a/apps/web/hooks/use-file-upload.ts +++ b/apps/web/shared/hooks/use-file-upload.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { toast } from "sonner"; import { api } from "@/shared/api"; import type { Attachment } from "@/shared/types"; @@ -24,6 +25,8 @@ const ALLOWED_TYPES = new Set([ ]); 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()); } @@ -64,5 +67,17 @@ export function useFileUpload() { [], ); - return { upload, uploading }; + const uploadWithToast = useCallback( + async (file: File, ctx?: UploadContext): Promise => { + try { + return await upload(file, ctx); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Upload failed"); + return null; + } + }, + [upload], + ); + + return { upload, uploadWithToast, uploading }; } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 9b75fd5a..8cc84c1c 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -110,6 +110,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route // Protected API routes r.Group(func(r chi.Router) { r.Use(middleware.Auth(queries)) + r.Use(middleware.RefreshCloudFrontCookies(cfSigner)) // --- User-scoped routes (no workspace context required) --- r.Get("/api/me", h.GetMe) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index 04c70649..50bedc9d 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -96,6 +96,7 @@ func (h *Handler) groupAttachments(r *http.Request, commentIDs []pgtype.UUID) ma } 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)) diff --git a/server/internal/middleware/cloudfront.go b/server/internal/middleware/cloudfront.go new file mode 100644 index 00000000..ab749998 --- /dev/null +++ b/server/internal/middleware/cloudfront.go @@ -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) + }) + } +} diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go index b29375f0..86167c18 100644 --- a/server/internal/storage/s3.go +++ b/server/internal/storage/s3.go @@ -68,13 +68,29 @@ func NewS3StorageFromEnv() *S3Storage { } } +// 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"`, strings.ReplaceAll(filename, `"`, "'"))), + ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, safe)), CacheControl: aws.String("max-age=432000,public"), StorageClass: types.StorageClassIntelligentTiering, }) From 98829fad290e9cc219b4cffc1bbc726ae92dee5c Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:03:13 +0800 Subject: [PATCH 32/36] fix(comments): replace optimistic updates with loading state - Remove temp-xxx optimistic inserts from submitComment/submitReply - Wait for API response, then insert real comment into timeline - Add Loader2 spinner to comment/reply submit buttons during loading - Remove hover card from Markdown.tsx (will be handled via NodeView later) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/components/markdown/Markdown.tsx | 12 ++---- .../issues/components/comment-input.tsx | 4 +- .../issues/components/reply-input.tsx | 4 +- .../issues/hooks/use-issue-timeline.ts | 42 ++++--------------- 4 files changed, 15 insertions(+), 47 deletions(-) diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 00038dad..d09f31a5 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -3,7 +3,6 @@ import ReactMarkdown, { type Components } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/utils' -import { MentionHoverCard } from '@/components/common/mention-hover-card' import { CodeBlock, InlineCode } from './CodeBlock' import { preprocessLinks } from './linkify' @@ -61,15 +60,10 @@ function createComponents( a: ({ href, children }) => { // Mention links: mention://member/id or mention://agent/id if (href?.startsWith('mention://')) { - const parts = href.replace('mention://', '').split('/') - const mentionType = parts[0] ?? 'member' - const mentionId = parts[1] ?? '' return ( - - - {children} - - + + {children} + ) } diff --git a/apps/web/features/issues/components/comment-input.tsx b/apps/web/features/issues/components/comment-input.tsx index 6e2b8052..6aaa2041 100644 --- a/apps/web/features/issues/components/comment-input.tsx +++ b/apps/web/features/issues/components/comment-input.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useState } from "react"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; @@ -44,7 +44,7 @@ function CommentInput({ onSubmit }: CommentInputProps) { disabled={isEmpty || submitting} onClick={handleSubmit} > - + {submitting ? : }
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index b95662c4..d58c8443 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useState } from "react"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { ActorAvatar } from "@/components/common/actor-avatar"; @@ -83,7 +83,7 @@ function ReplyInput({ onClick={handleSubmit} tabIndex={isEmpty ? -1 : 0} > - + {submitting ? : }
diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index 98530d9a..63555d0c 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -176,27 +176,14 @@ export function useIssueTimeline(issueId: string, userId?: string) { const submitComment = useCallback( async (content: string) => { 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); try { const comment = await api.createComment(issueId, content); - setTimeline((prev) => - prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e)), - ); + setTimeline((prev) => { + if (prev.some((e) => e.id === comment.id)) return prev; + return [...prev, commentToTimelineEntry(comment)]; + }); } catch { - setTimeline((prev) => prev.filter((e) => e.id !== tempId)); toast.error("Failed to send comment"); } finally { setSubmitting(false); @@ -208,26 +195,13 @@ export function useIssueTimeline(issueId: string, userId?: string) { const submitReply = useCallback( async (parentId: string, content: string) => { 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 { const comment = await api.createComment(issueId, content, "comment", parentId); - setTimeline((prev) => - prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e)), - ); + setTimeline((prev) => { + if (prev.some((e) => e.id === comment.id)) return prev; + return [...prev, commentToTimelineEntry(comment)]; + }); } catch { - setTimeline((prev) => prev.filter((e) => e.id !== tempId)); toast.error("Failed to send reply"); } }, From b5674869ed368afbfc60fb95d134b4680b98febb Mon Sep 17 00:00:00 2001 From: LinYushen Date: Tue, 31 Mar 2026 16:13:58 +0800 Subject: [PATCH 33/36] fix(auth): enforce auth on daemon API routes (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth): enforce auth middleware and workspace membership on daemon API routes Daemon routes were registered without the Auth middleware, meaning the server accepted unauthenticated requests to register runtimes, claim tasks, etc. The daemon client already sends a Bearer token — the server just wasn't validating it. - Split /api/daemon routes: pairing-session endpoints stay public (used before the daemon has a token), all others now require Auth middleware - Add workspace membership check in DaemonRegister so only workspace members can register runtimes - Update test to include X-User-ID header matching the new auth requirement Closes MUL-90 Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(daemon): remove dead pairing-session feature The daemon pairing flow was never completed — the daemon authenticates via CLI config token, not pairing sessions. Remove all related code: - Delete daemon_pairing.go handler (4 unused handlers) - Remove pairing routes from router.go (3 public + 1 protected) - Delete /pair/local page + test from frontend - Remove DaemonPairingSession types and API client methods - Add migration 029 to drop daemon_pairing_session table - Update LOCAL_DEVELOPMENT.md to reflect actual auth flow Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- LOCAL_DEVELOPMENT.md | 14 +- apps/web/app/pair/local/page.test.tsx | 107 ----- apps/web/app/pair/local/page.tsx | 181 -------- apps/web/shared/api/client.ts | 16 - apps/web/shared/types/daemon.ts | 22 - apps/web/shared/types/index.ts | 1 - server/cmd/server/router.go | 8 +- server/internal/handler/daemon.go | 6 + server/internal/handler/daemon_pairing.go | 386 ------------------ server/internal/handler/handler_test.go | 1 + .../029_drop_daemon_pairing.down.sql | 21 + .../migrations/029_drop_daemon_pairing.up.sql | 1 + 12 files changed, 33 insertions(+), 731 deletions(-) delete mode 100644 apps/web/app/pair/local/page.test.tsx delete mode 100644 apps/web/app/pair/local/page.tsx delete mode 100644 apps/web/shared/types/daemon.ts delete mode 100644 server/internal/handler/daemon_pairing.go create mode 100644 server/migrations/029_drop_daemon_pairing.down.sql create mode 100644 server/migrations/029_drop_daemon_pairing.up.sql diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index 397d8f88..50b9f80e 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -314,18 +314,8 @@ Run the local daemon: make daemon ``` -Normal flow: - -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 +The daemon authenticates using the CLI's stored token (`multica login`). +It registers runtimes for all watched workspaces from the CLI config. ## Troubleshooting diff --git a/apps/web/app/pair/local/page.test.tsx b/apps/web/app/pair/local/page.test.tsx deleted file mode 100644 index 38ec775b..00000000 --- a/apps/web/app/pair/local/page.test.tsx +++ /dev/null @@ -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; - created_at: string; - updated_at: string; - }>, - workspace: null as null | { - id: string; - name: string; - slug: string; - description: null; - settings: Record; - 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(); - - await waitFor(() => { - expect(mockGetDaemonPairingSession).toHaveBeenCalledWith("test-token"); - }); - - expect(await screen.findByText("Jiayuan's Workspace")).toBeInTheDocument(); - expect(screen.queryByText(mockWorkspace.id)).not.toBeInTheDocument(); - }); -}); diff --git a/apps/web/app/pair/local/page.tsx b/apps/web/app/pair/local/page.tsx deleted file mode 100644 index 6f94653d..00000000 --- a/apps/web/app/pair/local/page.tsx +++ /dev/null @@ -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(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 ( -
-
-
-

Connect Local Codex Runtime

-

- Approve this pairing request to register your local Codex runtime with a workspace. -

-
- - {loading || isLoading ? ( -
Loading pairing session...
- ) : error ? ( -
- {error} -
- ) : session ? ( - <> -
-
{session.runtime_name}
-
- {session.device_name} - {session.runtime_version ? ` · ${session.runtime_version}` : ""} -
-
- {session.runtime_type} -
-
- Expires {formatExpiresAt(session.expires_at)} -
-
- - {!user ? ( -
-

- Sign in first, then choose which workspace should own this local runtime. -

- - Sign in to continue - -
- ) : session.status === "approved" || session.status === "claimed" ? ( -
- This runtime is linked to a workspace. Return to the daemon window to finish setup. -
- ) : session.status === "expired" ? ( -
- This pairing link expired. Restart the daemon to generate a new link. -
- ) : workspaces.length === 0 ? ( -
- You do not have a workspace yet. Create one first, then reopen this pairing link. -
- ) : ( -
-
- - -
- - -
- )} - - ) : null} -
-
- ); -} - -export default function LocalDaemonPairPage() { - return ( - - - - ); -} diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index f6a080b0..9b41a9bd 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -12,8 +12,6 @@ import type { UpdateAgentRequest, AgentTask, AgentRuntime, - DaemonPairingSession, - ApproveDaemonPairingSessionRequest, InboxItem, IssueSubscriber, Comment, @@ -360,20 +358,6 @@ export class ApiClient { return this.fetch(`/api/issues/${issueId}/task-runs`); } - async getDaemonPairingSession(token: string): Promise { - return this.fetch(`/api/daemon/pairing-sessions/${token}`); - } - - async approveDaemonPairingSession( - token: string, - data: ApproveDaemonPairingSessionRequest, - ): Promise { - return this.fetch(`/api/daemon/pairing-sessions/${token}/approve`, { - method: "POST", - body: JSON.stringify(data), - }); - } - // Inbox async listInbox(): Promise { return this.fetch("/api/inbox"); diff --git a/apps/web/shared/types/daemon.ts b/apps/web/shared/types/daemon.ts deleted file mode 100644 index 459a67a5..00000000 --- a/apps/web/shared/types/daemon.ts +++ /dev/null @@ -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; -} diff --git a/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 4c105ff5..709c7f18 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -27,7 +27,6 @@ export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox"; export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment"; export type { TimelineEntry } from "./activity"; export type { IssueSubscriber } from "./subscriber"; -export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon"; export type * from "./events"; export type * from "./api"; export type { Attachment } from "./attachment"; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 8cc84c1c..400d3c40 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -83,11 +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/verify-code", h.VerifyCode) - // Daemon API routes (no user auth; daemon auth deferred to later) + // Daemon API routes (all require a valid token) r.Route("/api/daemon", func(r chi.Router) { - r.Post("/pairing-sessions", h.CreateDaemonPairingSession) - r.Get("/pairing-sessions/{token}", h.GetDaemonPairingSession) - r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession) + r.Use(middleware.Auth(queries)) r.Post("/register", h.DaemonRegister) r.Post("/deregister", h.DaemonDeregister) @@ -150,8 +148,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Delete("/{id}", h.RevokePersonalAccessToken) }) - r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession) - // --- Workspace-scoped routes (all require workspace membership) --- r.Group(func(r chi.Router) { r.Use(middleware.RequireWorkspaceMember(queries)) diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index d91b9376..ae7803c4 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -53,6 +53,12 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "at least one runtime is required") 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)) if err != nil { writeError(w, http.StatusNotFound, "workspace not found") diff --git a/server/internal/handler/daemon_pairing.go b/server/internal/handler/daemon_pairing.go deleted file mode 100644 index 9cf7747f..00000000 --- a/server/internal/handler/daemon_pairing.go +++ /dev/null @@ -1,386 +0,0 @@ -package handler - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -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"` -} - -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 - } - - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) -} diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 15e1769b..9a5ec3c8 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -729,6 +729,7 @@ func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) { "runtimes":[{"name":"Local Codex","type":"codex","version":"1.0.0","status":"online"}] }`)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", testUserID) testHandler.DaemonRegister(w, req) if w.Code != http.StatusNotFound { diff --git a/server/migrations/029_drop_daemon_pairing.down.sql b/server/migrations/029_drop_daemon_pairing.down.sql new file mode 100644 index 00000000..c39431e7 --- /dev/null +++ b/server/migrations/029_drop_daemon_pairing.down.sql @@ -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); diff --git a/server/migrations/029_drop_daemon_pairing.up.sql b/server/migrations/029_drop_daemon_pairing.up.sql new file mode 100644 index 00000000..25e28eb7 --- /dev/null +++ b/server/migrations/029_drop_daemon_pairing.up.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS daemon_pairing_session; From bb2dd679414006370595729e617150f14347ad7e Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:15:26 +0800 Subject: [PATCH 34/36] fix(comments): collapsible header overflow, hover style on toggle - Add shrink-0 to name/time to prevent wrapping when collapsed - Content preview: min-w-0 flex-1 truncate for proper ellipsis - Collapsible trigger: add rounded p-0.5 hover:bg-muted for click affordance Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/features/issues/components/comment-card.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 57091474..2bea2dda 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -245,17 +245,17 @@ function CommentCard({ {/* Header — always visible, acts as toggle */}
- + - + {getActorName(entry.actor_type, entry.actor_id)} + {timeAgo(entry.created_at)} } @@ -266,12 +266,12 @@ function CommentCard({ {!open && contentPreview && ( - - {contentPreview}{(entry.content ?? "").length > 80 ? "..." : ""} + + {contentPreview} )} {!open && replyCount > 0 && ( - + {replyCount} {replyCount === 1 ? "reply" : "replies"} )} From 27987adf37bae18d02631478beb090b0c2d66314 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:17:41 +0800 Subject: [PATCH 35/36] fix(inbox): use issue_id as selection key instead of inbox item id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - URL param: ?id= → ?issue= (keyed by issue, not notification) - Multiple notifications for same issue now share selection state - Archive correctly clears selection when archived item's issue matches Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/(dashboard)/inbox/page.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 9d3b62d2..83501a99 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -219,9 +219,9 @@ function InboxListItem({ export default function InboxPage() { const searchParams = useSearchParams(); - const selectedId = searchParams.get("id") ?? ""; - const setSelectedId = (id: string) => { - const url = id ? `/inbox?id=${id}` : "/inbox"; + const selectedKey = searchParams.get("issue") ?? ""; + const setSelectedKey = (key: string) => { + const url = key ? `/inbox?issue=${key}` : "/inbox"; window.history.replaceState(null, "", url); }; @@ -232,12 +232,12 @@ export default function InboxPage() { 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; // Click-to-read: select + auto-mark-read const handleSelect = async (item: InboxItem) => { - setSelectedId(item.id); + setSelectedKey(item.issue_id ?? item.id); if (!item.read) { useInboxStore.getState().markRead(item.id); try { @@ -254,7 +254,8 @@ export default function InboxPage() { try { await api.archiveInbox(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 { toast.error("Failed to archive"); } @@ -274,7 +275,7 @@ export default function InboxPage() { const handleArchiveAll = async () => { try { useInboxStore.getState().archiveAll(); - setSelectedId(""); + setSelectedKey(""); await api.archiveAllInbox(); } catch { toast.error("Failed to archive all"); @@ -284,9 +285,9 @@ export default function InboxPage() { const handleArchiveAllRead = async () => { 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(); - if (readIds.includes(selectedId)) setSelectedId(""); + if (readKeys.includes(selectedKey)) setSelectedKey(""); await api.archiveAllReadInbox(); } catch { toast.error("Failed to archive read items"); @@ -297,7 +298,7 @@ export default function InboxPage() { const handleArchiveCompleted = async () => { try { await api.archiveCompletedInbox(); - setSelectedId(""); + setSelectedKey(""); await useInboxStore.getState().fetch(); } catch { toast.error("Failed to archive completed"); @@ -395,7 +396,7 @@ export default function InboxPage() { handleSelect(item)} onArchive={() => handleArchive(item.id)} /> From d57b98fc789e0015c1f5005289acc08168a52b96 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 16:22:25 +0800 Subject: [PATCH 36/36] fix(api): add credentials include to fetch for cross-origin cookie storage The API at multica-api.copilothub.ai sets CloudFront signed cookies with Domain=.copilothub.ai, but fetch() defaults to credentials: 'same-origin'. Since the frontend (multica-app.copilothub.ai) and API are cross-origin, the browser silently drops Set-Cookie headers without credentials: 'include'. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/shared/api/client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 9b41a9bd..2419d7bc 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -107,6 +107,7 @@ export class ApiClient { const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, + credentials: "include", }); if (!res.ok) { @@ -527,6 +528,7 @@ export class ApiClient { method: "POST", headers: this.authHeaders(), body: formData, + credentials: "include", }); if (!res.ok) {