From e2453cc040de6ba0e2048025228a925b4bf640db Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:13:47 +0800 Subject: [PATCH 1/6] fix(inbox): pin header and only scroll content in inbox list panel The inbox left panel had the header inside the overflow-y-auto container, causing it to scroll away with the list items. Changed to flex-col layout with shrink-0 header and flex-1 overflow-y-auto content area. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/(dashboard)/inbox/page.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 1bd8ee89..65b3191b 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -309,11 +309,11 @@ export default function InboxPage() { return ( -
-
+
+
-
+
{Array.from({ length: 5 }).map((_, i) => (
@@ -341,8 +341,8 @@ export default function InboxPage() { {/* Left column — inbox list */} -
-
+
+

Inbox

{unreadCount > 0 && ( @@ -385,6 +385,7 @@ export default function InboxPage() {
+
{items.length === 0 ? (
@@ -403,6 +404,7 @@ export default function InboxPage() { ))}
)} +
From f891a5bbd78e2cb1983ee22b079267b93dc05f8a Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:37:33 +0800 Subject: [PATCH 2/6] refactor(web): unify assignee dropdowns with ActorAvatar and shared AssigneePicker - Replace inline initials/Bot divs with ActorAvatar across all assignee UIs - Replace issue-detail sidebar DropdownMenu with shared AssigneePicker - Delete BatchAssigneePicker (~130 lines), reuse AssigneePicker in controlled mode - Add controlled mode (open/onOpenChange), align, and triggerRender props to AssigneePicker/PropertyPicker - Add canAssignAgent visibility check to issue-detail more menu - Clean up unused imports (Bot, useAuthStore, useWorkspaceStore, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/batch-action-toolbar.tsx | 152 ++---------------- apps/web/features/issues/components/index.ts | 2 +- .../issues/components/issue-detail.tsx | 74 ++------- .../issues/components/issues-header.tsx | 14 +- .../components/pickers/assignee-picker.tsx | 43 +++-- .../issues/components/pickers/index.ts | 2 +- .../components/pickers/property-picker.tsx | 7 +- 7 files changed, 54 insertions(+), 240 deletions(-) diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index e52365f4..0d6e3c2a 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, Lock, UserMinus } from "lucide-react"; +import { X, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -19,15 +19,14 @@ import { PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; -import type { Agent, UpdateIssueRequest } from "@/shared/types"; +import type { 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"; import { api } from "@/shared/api"; import { StatusIcon } from "./status-icon"; import { PriorityIcon } from "./priority-icon"; +import { AssigneePicker } from "./pickers"; export function BatchActionToolbar() { const selectedIds = useIssueSelectionStore((s) => s.selectedIds); @@ -44,7 +43,7 @@ export function BatchActionToolbar() { const ids = Array.from(selectedIds); - const handleBatchUpdate = async (updates: UpdateIssueRequest) => { + const handleBatchUpdate = async (updates: Partial) => { setLoading(true); try { await api.batchUpdateIssues(ids, updates); @@ -161,11 +160,15 @@ export function BatchActionToolbar() { {/* Assignee */} - } + trigger="Assignee" + align="center" /> {/* Delete */} @@ -207,136 +210,3 @@ 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, - onUpdate, - loading, -}: { - open: boolean; - onOpenChange: (v: boolean) => void; - onUpdate: (updates: UpdateIssueRequest) => void; - 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), - ); - const filteredAgents = agents.filter((a) => - a.name.toLowerCase().includes(query), - ); - - return ( - { - onOpenChange(v); - if (!v) setFilter(""); - }} - > - - } - > - Assignee - - -
- setFilter(e.target.value)} - placeholder="Assign to..." - className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" - /> -
-
- - - {filteredMembers.length > 0 && ( -
-
- Members -
- {filteredMembers.map((m) => ( - - ))} -
- )} - - {filteredAgents.length > 0 && ( -
-
- Agents -
- {filteredAgents.map((a) => { - const allowed = canAssignAgent(a, user?.id, memberRole); - return ( - - ); - })} -
- )} -
-
-
- ); -} diff --git a/apps/web/features/issues/components/index.ts b/apps/web/features/issues/components/index.ts index 37608d08..6df36291 100644 --- a/apps/web/features/issues/components/index.ts +++ b/apps/web/features/issues/components/index.ts @@ -1,6 +1,6 @@ export { StatusIcon } from "./status-icon"; export { PriorityIcon } from "./priority-icon"; -export { StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers"; +export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker } from "./pickers"; export { IssueDetail } from "./issue-detail"; export { IssuesPage } from "./issues-page"; export { CommentCard } from "./comment-card"; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 7eddf905..3b5ea855 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -5,7 +5,6 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { - Bot, Calendar, Check, ChevronLeft, @@ -58,7 +57,7 @@ import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/compone import { ActorAvatar } from "@/components/common/actor-avatar"; import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; -import { StatusIcon, PriorityIcon, DueDatePicker } from "@/features/issues/components"; +import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components"; import { CommentCard } from "./comment-card"; import { CommentInput } from "./comment-input"; import { AgentLiveCard, TaskRunHistory } from "./agent-live-card"; @@ -174,6 +173,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const workspace = useWorkspaceStore((s) => s.workspace); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); + const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role; // Issue navigation const allIssues = useIssueStore((s) => s.issues); @@ -421,21 +421,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })} > -
- {getActorInitials("member", m.user_id)} -
+ {m.name} {issue.assignee_type === "member" && issue.assignee_id === m.user_id && } ))} - {agents.map((a) => ( + {agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => ( handleUpdateField({ assignee_type: "agent", assignee_id: a.id })} > -
- -
+ {a.name} {issue.assignee_type === "agent" && issue.assignee_id === a.id && }
@@ -873,60 +869,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {/* Assignee */} - - - {issue.assignee_type && issue.assignee_id ? ( - <> - - {getActorName(issue.assignee_type, issue.assignee_id)} - - ) : ( - Unassigned - )} - - - handleUpdateField({ assignee_type: null, assignee_id: null })}> - - Unassigned - - {members.length > 0 && ( - <> - - - Members - {members.map((m) => ( - handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}> -
- {getActorInitials("member", m.user_id)} -
- {m.name} -
- ))} -
- - )} - {agents.length > 0 && ( - <> - - - Agents - {agents.map((a) => ( - handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}> -
- -
- {a.name} -
- ))} -
- - )} -
-
+
{/* Due date */} diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index 33b6680d..27c28d59 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -4,7 +4,6 @@ import { useMemo, useState } from "react"; import { ArrowDown, ArrowUp, - Bot, Check, ChevronDown, CircleDot, @@ -47,7 +46,8 @@ import { PRIORITY_CONFIG, } from "@/features/issues/config"; import { StatusIcon, PriorityIcon } from "@/features/issues/components"; -import { useWorkspaceStore, useActorName } from "@/features/workspace"; +import { useWorkspaceStore } from "@/features/workspace"; +import { ActorAvatar } from "@/components/common/actor-avatar"; import { useIssueViewStore, SORT_OPTIONS, @@ -147,8 +147,6 @@ function ActorSubContent({ const [search, setSearch] = useState(""); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); - const { getActorInitials } = useActorName(); - const query = search.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(query), @@ -208,9 +206,7 @@ function ActorSubContent({ className={FILTER_ITEM_CLASS} > -
- {getActorInitials("member", m.user_id)} -
+ {m.name} {count > 0 && ( @@ -239,9 +235,7 @@ function ActorSubContent({ className={FILTER_ITEM_CLASS} > -
- -
+ {a.name} {count > 0 && ( diff --git a/apps/web/features/issues/components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx index 5ffd0d2c..c75589ba 100644 --- a/apps/web/features/issues/components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -1,10 +1,11 @@ "use client"; import { useState } from "react"; -import { Bot, Lock, UserMinus } from "lucide-react"; +import { Lock, UserMinus } from "lucide-react"; import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; +import { ActorAvatar } from "@/components/common/actor-avatar"; import { PropertyPicker, PickerItem, @@ -12,7 +13,7 @@ import { PickerEmpty, } from "./property-picker"; -function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean { +export 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; @@ -24,18 +25,28 @@ export function AssigneePicker({ assigneeId, onUpdate, trigger: customTrigger, + triggerRender, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + align, }: { assigneeType: IssueAssigneeType | null; assigneeId: string | null; onUpdate: (updates: Partial) => void; trigger?: React.ReactNode; + triggerRender?: React.ReactElement; + open?: boolean; + onOpenChange?: (v: boolean) => void; + align?: "start" | "center" | "end"; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = controlledOnOpenChange ?? setInternalOpen; const [filter, setFilter] = useState(""); const user = useAuthStore((s) => s.user); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); - const { getActorName, getActorInitials } = useActorName(); + const { getActorName } = useActorName(); const currentMember = members.find((m) => m.user_id === user?.id); const memberRole = currentMember?.role; @@ -64,25 +75,15 @@ export function AssigneePicker({ if (!v) setFilter(""); }} width="w-52" + align={align} searchable searchPlaceholder="Assign to..." onSearchChange={setFilter} + triggerRender={triggerRender} trigger={ customTrigger ? customTrigger : assigneeType && assigneeId ? ( <> -
- {assigneeType === "agent" ? ( - - ) : ( - getActorInitials(assigneeType, assigneeId) - )} -
+ {triggerLabel} ) : ( @@ -117,9 +118,7 @@ export function AssigneePicker({ setOpen(false); }} > -
- {getActorInitials("member", m.user_id)} -
+ {m.name} ))} @@ -145,9 +144,7 @@ export function AssigneePicker({ setOpen(false); }} > -
- -
+ {a.name} {a.visibility === "private" && ( diff --git a/apps/web/features/issues/components/pickers/index.ts b/apps/web/features/issues/components/pickers/index.ts index 12a238c6..3b5876bc 100644 --- a/apps/web/features/issues/components/pickers/index.ts +++ b/apps/web/features/issues/components/pickers/index.ts @@ -1,5 +1,5 @@ export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker"; export { StatusPicker } from "./status-picker"; export { PriorityPicker } from "./priority-picker"; -export { AssigneePicker } from "./assignee-picker"; +export { AssigneePicker, canAssignAgent } from "./assignee-picker"; export { DueDatePicker } from "./due-date-picker"; diff --git a/apps/web/features/issues/components/pickers/property-picker.tsx b/apps/web/features/issues/components/pickers/property-picker.tsx index 1329fa4d..6a53c82f 100644 --- a/apps/web/features/issues/components/pickers/property-picker.tsx +++ b/apps/web/features/issues/components/pickers/property-picker.tsx @@ -16,6 +16,7 @@ export function PropertyPicker({ open, onOpenChange, trigger, + triggerRender, width = "w-48", align = "end", searchable = false, @@ -26,6 +27,7 @@ export function PropertyPicker({ open: boolean; onOpenChange: (v: boolean) => void; trigger: React.ReactNode; + triggerRender?: React.ReactElement; width?: string; align?: "start" | "center" | "end"; searchable?: boolean; @@ -48,7 +50,10 @@ export function PropertyPicker({ return ( - + {trigger} From 98e7d27acca7b2373ebcf467a72fcb48a2beebfd Mon Sep 17 00:00:00 2001 From: LinYushen Date: Wed, 1 Apr 2026 15:57:23 +0800 Subject: [PATCH 3/6] feat(cli): add --attachment flag to issue comment add (#260) Add file attachment support to `multica issue comment add`. The CLI uploads files via multipart form to /api/upload-file, collects the returned attachment IDs, and passes them when creating the comment. Usage: multica issue comment add --content "..." --attachment file1.png --attachment file2.pdf Co-authored-by: Claude Opus 4.6 (1M context) --- server/cmd/multica/cmd_issue.go | 33 +++++++++++++++++-- server/internal/cli/client.go | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index e7783354..9ce65c1e 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -147,6 +147,7 @@ func init() { // issue comment add issueCommentAddCmd.Flags().String("content", "", "Comment content (required)") issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)") + issueCommentAddCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)") issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json") } @@ -540,19 +541,45 @@ func runIssueCommentAdd(cmd *cobra.Command, args []string) error { return err } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + issueID := args[0] + + // Use a longer timeout when attachments are present (file uploads can be slow). + timeout := 15 * time.Second + attachments, _ := cmd.Flags().GetStringSlice("attachment") + if len(attachments) > 0 { + timeout = 60 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + // Upload attachments and collect their IDs. + var attachmentIDs []string + for _, filePath := range attachments { + data, readErr := os.ReadFile(filePath) + if readErr != nil { + return fmt.Errorf("read attachment %s: %w", filePath, readErr) + } + id, uploadErr := client.UploadFile(ctx, data, filePath, issueID) + if uploadErr != nil { + return fmt.Errorf("upload attachment %s: %w", filePath, uploadErr) + } + attachmentIDs = append(attachmentIDs, id) + fmt.Fprintf(os.Stderr, "Uploaded %s\n", filePath) + } + body := map[string]any{"content": content} if parentID, _ := cmd.Flags().GetString("parent"); parentID != "" { body["parent_id"] = parentID } + if len(attachmentIDs) > 0 { + body["attachment_ids"] = attachmentIDs + } var result map[string]any - if err := client.PostJSON(ctx, "/api/issues/"+args[0]+"/comments", body, &result); err != nil { + if err := client.PostJSON(ctx, "/api/issues/"+issueID+"/comments", body, &result); err != nil { return fmt.Errorf("add comment: %w", err) } - fmt.Fprintf(os.Stderr, "Comment added to issue %s.\n", truncateID(args[0])) + fmt.Fprintf(os.Stderr, "Comment added to issue %s.\n", truncateID(issueID)) output, _ := cmd.Flags().GetString("output") if output == "table" { diff --git a/server/internal/cli/client.go b/server/internal/cli/client.go index 312276d0..8cfce31b 100644 --- a/server/internal/cli/client.go +++ b/server/internal/cli/client.go @@ -6,7 +6,9 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "path/filepath" "strings" "time" ) @@ -156,6 +158,60 @@ func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) return json.NewDecoder(resp.Body).Decode(out) } +// UploadFile uploads a file via multipart form to /api/upload-file. +// It returns the attachment ID from the server response. +func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile("file", filepath.Base(filename)) + if err != nil { + return "", fmt.Errorf("create form file: %w", err) + } + if _, err := part.Write(fileData); err != nil { + return "", fmt.Errorf("write file data: %w", err) + } + + if issueID != "" { + if err := writer.WriteField("issue_id", issueID); err != nil { + return "", fmt.Errorf("write issue_id field: %w", err) + } + } + + if err := writer.Close(); err != nil { + return "", fmt.Errorf("close multipart writer: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("upload file returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("decode upload response: %w", err) + } + + id, _ := result["id"].(string) + if id == "" { + return "", fmt.Errorf("upload response missing attachment id") + } + return id, nil +} + // HealthCheck hits the /health endpoint and returns the response body. func (c *APIClient) HealthCheck(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil) From 3e96240cec92459270fefe85fcdbc3e974c18f85 Mon Sep 17 00:00:00 2001 From: LinYushen Date: Wed, 1 Apr 2026 15:58:20 +0800 Subject: [PATCH 4/6] docs: align AGENTS.md with CLAUDE.md content (#263) AGENTS.md was a minimal 17-line summary while CLAUDE.md had comprehensive project documentation. Updated AGENTS.md to include all sections from CLAUDE.md (architecture, state management, backend structure, UI/UX rules, worktree support, E2E patterns, verification loop) while preserving AGENTS.md-unique details (naming conventions, test directory, PR guidelines). Co-authored-by: Claude Opus 4.6 (1M context) --- AGENTS.md | 278 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 268 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd66fd32..8b224583 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,274 @@ # Repository Guidelines -## Project Structure & Module Organization -`apps/web/` contains the Next.js 16 frontend: routes live in `app/`, reusable UI in `components/`, feature code in `features/`, test utilities in `test/`, and static assets in `public/`. `server/` contains the Go backend: entry points are in `cmd/{server,multica,migrate}`, application logic lives in `internal/`, migrations are in `migrations/`, and SQL lives under `pkg/db/queries/` with generated sqlc output in `pkg/db/generated/`. `e2e/` holds Playwright coverage. `scripts/` and the root `Makefile` drive local setup and verification. +This file provides guidance to AI agents when working with code in this repository. -## Build, Test, and Development Commands -Use `make setup` for first-time setup: it installs dependencies, ensures PostgreSQL is running, and applies migrations. Use `make start` to run backend and frontend together with `.env` or `.env.worktree`. For single-surface work, use `pnpm dev:web` for the frontend and `make dev` for the Go server. Run `pnpm test` for Vitest, `make test` for Go tests, and `make check` for the full pipeline: typecheck, frontend unit tests, Go tests, then Playwright. After changing SQL in `server/pkg/db/queries/*.sql`, run `make sqlc`. +## Project Context -## Coding Style & Naming Conventions -TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons. Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`. Go code should stay `gofmt`-clean and use domain-oriented filenames like `issue.go` or `cmd_issue.go`. Do not hand-edit generated code in `server/pkg/db/generated/`. +Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens. -## Testing Guidelines -Frontend unit tests use Vitest with Testing Library and shared setup from `apps/web/test/`. End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running. Backend tests use Go’s standard `*_test.go` pattern. Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows. +- Agents can be assigned issues, create issues, comment, and change status +- Supports local (daemon) and cloud agent runtimes +- Built for 2-10 person AI-native teams -## Commit & Pull Request Guidelines -Recent history follows conventional commits with scopes, for example `feat(web): ...`, `fix(cli): ...`, `refactor(daemon): ...`, `test(cli): ...`, and `docs: ...`. Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes. Before opening a PR, run `make check` or the relevant frontend/backend subset. +## Architecture + +**Go backend + standalone Next.js frontend.** + +- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time) +- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies +- `e2e/` — Playwright end-to-end tests +- `scripts/` and root `Makefile` — local setup and verification + +### Web App Structure (`apps/web/`) + +The frontend uses a **feature-based architecture** with four layers: + +``` +apps/web/ +├── app/ # Routing layer (thin shells — import from features/) +├── features/ # Business logic, organized by domain +├── shared/ # Cross-feature utilities (api client, types, logger) +├── test/ # Shared test utilities and setup +├── public/ # Static assets +``` + +**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`. + +**`features/`** — Domain modules, each with its own components, hooks, stores, and config: + +| Feature | Purpose | Exports | +|---|---|---| +| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` | +| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` | +| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config | +| `features/inbox/` | Inbox notification state | `useInboxStore` | +| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` | +| `features/modals/` | Modal registry and state | Modal store and components | +| `features/skills/` | Skill management | Skill components | + +**`shared/`** — Code used across multiple features: +- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton. +- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types. +- `shared/logger.ts` — Logger utility. + +### State Management + +- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`). +- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`). +- **Local `useState`** for component-scoped UI state (forms, modals, filters). +- Do not use React Context for data that can be a zustand store. + +**Store conventions:** +- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`. +- Stores must not call `useRouter` or any React hooks — keep navigation in components. +- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks). +- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse. + +### Import Aliases + +Use `@/` alias (maps to `apps/web/`): +```typescript +import { api } from "@/shared/api"; +import type { Issue } from "@/shared/types"; +import { useAuthStore } from "@/features/auth"; +import { useWorkspaceStore } from "@/features/workspace"; +import { useIssueStore } from "@/features/issues"; +import { useInboxStore } from "@/features/inbox"; +import { useWSEvent } from "@/features/realtime"; +import { StatusIcon } from "@/features/issues/components"; +``` + +Within a feature, use relative imports. Between features or to shared, use `@/`. + +### Data Flow + +``` +Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL +Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService +``` + +### Backend Structure (`server/`) + +- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate` +- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`. +- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO. +- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found. +- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition. +- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels. +- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider. +- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting. +- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services. +- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error). +- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`. +- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model). + +### Multi-tenancy + +All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace. + +### Agent Assignees + +Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon). + +## Commands + +```bash +# One-click setup & run +make setup # First-time: ensure shared DB, create app DB, migrate +make start # Start backend + frontend together +make stop # Stop app processes for the current checkout +make db-down # Stop the shared PostgreSQL container + +# Frontend +pnpm install +pnpm dev:web # Next.js dev server (port 3000) +pnpm build # Build frontend +pnpm typecheck # TypeScript check +pnpm lint # ESLint via Next.js +pnpm test # TS tests (Vitest) + +# Backend (Go) +make dev # Run Go server (port 8080) +make daemon # Run local daemon +make build # Build server + CLI binaries to server/bin/ +make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config") +make test # Go tests +make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/ +make migrate-up # Run database migrations +make migrate-down # Rollback migrations + +# Run a single Go test +cd server && go test ./internal/handler/ -run TestName + +# Run a single TS test +pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts + +# Run a single E2E test (requires backend + frontend running) +pnpm exec playwright test e2e/tests/specific-test.spec.ts + +# Infrastructure +make db-up # Start shared PostgreSQL (pgvector/pg17 image) +make db-down # Stop shared PostgreSQL +``` + +### CI Requirements + +CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`. + +### Worktree Support + +All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`. + +```bash +make worktree-env # Generate .env.worktree with unique DB/ports +make setup-worktree # Setup using .env.worktree +make start-worktree # Start using .env.worktree +``` + +## Coding Rules + +- TypeScript strict mode is enabled; keep types explicit. +- TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons. +- Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`. +- Go code follows standard Go conventions (gofmt, go vet). Use domain-oriented filenames like `issue.go` or `cmd_issue.go`. +- Do not hand-edit generated code in `server/pkg/db/generated/`. +- Keep comments in code **English only**. +- Prefer existing patterns/components over introducing parallel abstractions. +- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims. +- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior. +- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about. +- Avoid broad refactors unless required by the task. + +## UI/UX Rules + +- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`. +- **Feature-specific components** → `features//components/` — issue icons, pickers, and other domain-bound UI live inside their feature module. +- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`). +- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context. +- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency. +- When unsure about interaction or state design, ask — the user will provide direction. + +## Testing Rules + +- **TypeScript**: Vitest with Testing Library. Shared test setup lives in `apps/web/test/`. Mock external/third-party dependencies only. +- **Go**: Standard `go test`. Tests should create their own fixture data in a test database. +- End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running. +- Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows. + +## Commit & Pull Request Rules + +- Use atomic commits grouped by logical intent. +- Conventional format with scopes: + - `feat(web): ...`, `feat(cli): ...` + - `fix(web): ...`, `fix(cli): ...` + - `refactor(daemon): ...` + - `test(cli): ...` + - `docs: ...` + - `chore(scope): ...` +- Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes. +- Before opening a PR, run `make check` or the relevant frontend/backend subset. + +## Minimum Pre-Push Checks + +```bash +make check # Runs all checks: typecheck, unit tests, Go tests, E2E +``` + +Run verification only when the user explicitly asks for it. + +For targeted checks when requested: +```bash +pnpm typecheck # TypeScript type errors only +pnpm test # TS unit tests only (Vitest) +make test # Go tests only +pnpm exec playwright test # E2E only (requires backend + frontend running) +``` + +## AI Agent Verification Loop + +After writing or modifying code, always run the full verification pipeline: + +```bash +make check +``` + +This runs all checks in sequence: +1. TypeScript typecheck (`pnpm typecheck`) +2. TypeScript unit tests (`pnpm test`) +3. Go tests (`go test ./...`) +4. E2E tests (auto-starts backend + frontend if needed, runs Playwright) + +**Workflow:** +- Write code to satisfy the requirement +- Run `make check` +- If any step fails, read the error output, fix the code, and re-run `make check` +- Repeat until all checks pass +- Only then consider the task complete + +**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete. + +## E2E Test Patterns + +E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown: + +```typescript +import { loginAsDefault, createTestApi } from "./helpers"; +import type { TestApiClient } from "./fixtures"; + +let api: TestApiClient; + +test.beforeEach(async ({ page }) => { + api = await createTestApi(); // logged-in API client + await loginAsDefault(page); // browser session +}); + +test.afterEach(async () => { + await api.cleanup(); // delete any data created during the test +}); + +test("example", async ({ page }) => { + const issue = await api.createIssue("Test Issue"); // create via API + await page.goto(`/issues/${issue.id}`); // test via UI + // api.cleanup() in afterEach removes the issue +}); +``` From 005025b05c15fbded9fe71a204242c117346a3b8 Mon Sep 17 00:00:00 2001 From: LinYushen Date: Wed, 1 Apr 2026 16:15:59 +0800 Subject: [PATCH 5/6] fix(server): allow @agent mentions to trigger regardless of issue status (#267) Remove terminal status (done/cancelled) checks that blocked @agent mention triggers and task claiming. Agents should always be triggerable via explicit @mentions, regardless of the issue's current status. Co-authored-by: Claude Opus 4.6 (1M context) --- server/internal/handler/comment.go | 5 ----- server/internal/service/task.go | 11 ----------- 2 files changed, 16 deletions(-) diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 93cf8f3f..4815304f 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -208,11 +208,6 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I // 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" { diff --git a/server/internal/service/task.go b/server/internal/service/task.go index f46d0e14..fb44e613 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -162,8 +162,6 @@ func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.A // ClaimTaskForRuntime claims the next runnable task for a runtime while // still respecting each agent's max_concurrent_tasks limit. -// Tasks whose issues are in a terminal status (done/cancelled) are -// automatically cancelled and skipped. func (s *TaskService) ClaimTaskForRuntime(ctx context.Context, runtimeID pgtype.UUID) (*db.AgentTaskQueue, error) { tasks, err := s.Queries.ListPendingTasksByRuntime(ctx, runtimeID) if err != nil { @@ -172,15 +170,6 @@ func (s *TaskService) ClaimTaskForRuntime(ctx context.Context, runtimeID pgtype. triedAgents := map[string]struct{}{} for _, candidate := range tasks { - // Skip tasks whose issues have reached a terminal status. - if issue, err := s.Queries.GetIssue(ctx, candidate.IssueID); err == nil { - if issue.Status == "done" || issue.Status == "cancelled" { - slog.Info("skipping task for terminal issue", "task_id", util.UUIDToString(candidate.ID), "issue_status", issue.Status) - _ = s.Queries.CancelAgentTasksByIssue(ctx, candidate.IssueID) - continue - } - } - agentKey := util.UUIDToString(candidate.AgentID) if _, seen := triedAgents[agentKey]; seen { continue From 9c249f07705acdf7eb5d64b30a743354216ab9fb Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 1 Apr 2026 18:10:43 +0800 Subject: [PATCH 6/6] feat(server,cli): improve attachment support across issue and comment APIs - Add --attachment flag to `multica issue create` CLI command - Fix CreateComment response to include linked attachments instead of empty array - Include attachments inline in GetIssue API response (matching Jira/ClickUp pattern) Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/multica/cmd_issue.go | 22 +++++++++++++++++++++- server/internal/handler/comment.go | 4 +++- server/internal/handler/issue.go | 13 +++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 9ce65c1e..9f341347 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -123,6 +123,7 @@ func init() { issueCreateCmd.Flags().String("parent", "", "Parent issue ID") issueCreateCmd.Flags().String("due-date", "", "Due date (RFC3339 format)") issueCreateCmd.Flags().String("output", "json", "Output format: table or json") + issueCreateCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)") // issue update issueUpdateCmd.Flags().String("title", "", "New title") @@ -276,7 +277,13 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error { return err } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + // Use a longer timeout when attachments are present (file uploads can be slow). + timeout := 15 * time.Second + attachments, _ := cmd.Flags().GetStringSlice("attachment") + if len(attachments) > 0 { + timeout = 60 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() body := map[string]any{"title": title} @@ -309,6 +316,19 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error { return fmt.Errorf("create issue: %w", err) } + // Upload attachments and link them to the newly created issue. + issueID := strVal(result, "id") + for _, filePath := range attachments { + data, readErr := os.ReadFile(filePath) + if readErr != nil { + return fmt.Errorf("read attachment %s: %w", filePath, readErr) + } + if _, uploadErr := client.UploadFile(ctx, data, filePath, issueID); uploadErr != nil { + return fmt.Errorf("upload attachment %s: %w", filePath, uploadErr) + } + fmt.Fprintf(os.Stderr, "Uploaded %s\n", filePath) + } + output, _ := cmd.Flags().GetString("output") if output == "table" { headers := []string{"ID", "TITLE", "STATUS", "PRIORITY"} diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 4815304f..215fedf4 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -148,7 +148,9 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, req.AttachmentIDs) } - resp := commentToResponse(comment, nil, nil) + // Fetch linked attachments so the response includes them. + groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID}) + resp := commentToResponse(comment, nil, groupedAtt[uuidToString(comment.ID)]) 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, diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 0cf7dc17..0c5a0d6a 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -36,6 +36,7 @@ type IssueResponse struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Reactions []IssueReactionResponse `json:"reactions,omitempty"` + Attachments []AttachmentResponse `json:"attachments,omitempty"` } type agentTriggerSnapshot struct { @@ -142,6 +143,18 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) { } } + // Fetch issue-level attachments. + attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + }) + if err == nil && len(attachments) > 0 { + resp.Attachments = make([]AttachmentResponse, len(attachments)) + for i, a := range attachments { + resp.Attachments[i] = h.attachmentToResponse(a) + } + } + writeJSON(w, http.StatusOK, resp) }