From a8e18cc15cf61646dd5e8126620306dd9e3ac904 Mon Sep 17 00:00:00 2001
From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Date: Tue, 24 Mar 2026 13:16:55 +0800
Subject: [PATCH 1/9] refactor(web): replace raw HTML with shadcn components on
login page
Use Card, Input, Button, and Label from @multica/ui for consistent design system.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
apps/web/app/(auth)/login/page.tsx | 88 +++++++++++++++++++-----------
1 file changed, 56 insertions(+), 32 deletions(-)
diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx
index 49c50fc5..67311661 100644
--- a/apps/web/app/(auth)/login/page.tsx
+++ b/apps/web/app/(auth)/login/page.tsx
@@ -2,6 +2,17 @@
import { useState } from "react";
import { useAuth } from "../../../lib/auth-context";
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+} from "@multica/ui/components/ui/card";
+import { Input } from "@multica/ui/components/ui/input";
+import { Button } from "@multica/ui/components/ui/button";
+import { Label } from "@multica/ui/components/ui/label";
export default function LoginPage() {
const { login, isLoading } = useAuth();
@@ -28,38 +39,51 @@ export default function LoginPage() {
return (
-
+
+
+ Multica
+ AI-native task management
+
+
+
+
+
+
+
+
);
}
From 3bdd1191a7caab16ec0ce6958e23dc5c945531c8 Mon Sep 17 00:00:00 2001
From: Jiayuan Zhang
Date: Tue, 24 Mar 2026 14:12:24 +0800
Subject: [PATCH 2/9] docs: make CLAUDE verification opt-in
---
CLAUDE.md | 31 +++++--------------------------
1 file changed, 5 insertions(+), 26 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 57c1342a..664541a0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -82,13 +82,15 @@ docker compose down # Stop PostgreSQL
- `test(scope): ...`
- `chore(scope): ...`
-## 8. Minimum Pre-Push Checks
+## 8. Verification Commands
```bash
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
```
-For individual checks during development:
+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)
@@ -96,30 +98,7 @@ make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
-## 9. 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.
-
-## 10. E2E Test Patterns
+## 9. E2E Test Patterns
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
From 8f4680c0e91209ff27e11ff216bee75f712f6f31 Mon Sep 17 00:00:00 2001
From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Date: Tue, 24 Mar 2026 14:13:55 +0800
Subject: [PATCH 3/9] refactor(web): unify design system with shadcn components
and semantic tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add success/warning/info semantic design tokens to globals.css
- Replace all raw HTML elements (input, select, textarea, button, label)
with shadcn components (Input, Select, Textarea, Button, Label, Dialog)
across settings, issues, agents, inbox, knowledge-base, and pair pages
- Replace all hardcoded Tailwind colors with design tokens
(text-red-500 → text-destructive, text-green-600 → text-success, etc.)
- Extract shared ActorAvatar component to packages/ui/components/common
- Update status and priority configs to use semantic tokens
- Update CLAUDE.md with component organization guidelines
- Fix login page tests to use label-based queries
Co-Authored-By: Claude Opus 4.6 (1M context)
---
CLAUDE.md | 3 +
apps/web/app/(auth)/login/page.test.tsx | 16 +-
.../(dashboard)/_components/app-sidebar.tsx | 118 +++----
apps/web/app/(dashboard)/agents/page.tsx | 319 +++++++++---------
apps/web/app/(dashboard)/inbox/page.tsx | 13 +-
apps/web/app/(dashboard)/issues/[id]/page.tsx | 125 +++----
.../(dashboard)/issues/_config/priority.ts | 8 +-
.../app/(dashboard)/issues/_config/status.ts | 8 +-
apps/web/app/(dashboard)/issues/page.tsx | 180 +++++-----
.../app/(dashboard)/knowledge-base/page.tsx | 10 +-
apps/web/app/(dashboard)/settings/page.tsx | 135 ++++----
apps/web/app/pair/local/page.tsx | 45 ++-
packages/ui/package.json | 1 +
.../ui/src/components/common/actor-avatar.tsx | 44 +++
packages/ui/src/styles/globals.css | 12 +
15 files changed, 557 insertions(+), 480 deletions(-)
create mode 100644 packages/ui/src/components/common/actor-avatar.tsx
diff --git a/CLAUDE.md b/CLAUDE.md
index 57c1342a..13c0adb9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,6 +62,9 @@ docker compose down # Stop PostgreSQL
## 5. UI/UX Rules
- Prefer `packages/ui` shadcn components over custom implementations.
+- **shadcn official components** → `packages/ui/src/components/ui/` — keep this directory clean; install missing components via `npx shadcn add`, do not mix in business code.
+- **Shared business components & utils** → `packages/ui/src/components/common/` — reusable project-level UI components (e.g. StatusBadge, PriorityIcon) and shared utilities live here.
+- 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.
- 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.
diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx
index d9d97164..84f484ef 100644
--- a/apps/web/app/(auth)/login/page.test.tsx
+++ b/apps/web/app/(auth)/login/page.test.tsx
@@ -44,9 +44,9 @@ describe("LoginPage", () => {
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
- expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
- expect(screen.getByPlaceholderText("Name")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Sign in" })).toBeInTheDocument();
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
+ expect(screen.getByLabelText("Name")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
});
it("does not call login when email is empty", async () => {
@@ -64,8 +64,8 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render();
- await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
- await user.type(screen.getByPlaceholderText("Name"), "Test User");
+ await user.type(screen.getByLabelText("Email"), "test@multica.ai");
+ await user.type(screen.getByLabelText("Name"), "Test User");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
@@ -78,7 +78,7 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render();
- await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
@@ -92,7 +92,7 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render();
- await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
@@ -105,7 +105,7 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render();
- await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
index 406f4713..b8999e26 100644
--- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx
+++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
@@ -26,6 +26,17 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@multica/ui/components/ui/sidebar";
+import { Input } from "@multica/ui/components/ui/input";
+import { Label } from "@multica/ui/components/ui/label";
+import { Button } from "@multica/ui/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@multica/ui/components/ui/dialog";
import { useAuth } from "../../../lib/auth-context";
import { useTabStore } from "../../../lib/tab-store";
@@ -165,7 +176,7 @@ export function AppSidebar() {
setShowMenu(false);
logout();
}}
- className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive hover:bg-accent"
>
Sign out
@@ -230,66 +241,57 @@ export function AppSidebar() {
{/* Create Workspace Dialog */}
- {showCreateDialog && (
- <>
- setShowCreateDialog(false)}
- />
-
-
-
- Create workspace
-
-
- Create a new workspace for your team.
-
+
-
);
@@ -389,74 +390,73 @@ function AddToolDialog({
};
return (
- <>
-
-
-
Add Tool
-
- Connect an external tool for this agent to use.
-
+
{ if (!v) onClose(); }}>
+
+
+ Add Tool
+
+ Connect an external tool for this agent to use.
+
+
-
+
-
-
+
+
Cancel
-
-
+
Add
-
-
-
- >
+
+
+
+
);
}
@@ -507,22 +507,23 @@ function ToolsTab({
{isDirty && (
-
{saving ? "Saving..." : "Save"}
-
+
)}
-
setShowAdd(true)}
- className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Add Tool
-
+
@@ -530,13 +531,14 @@ function ToolsTab({
No tools configured
-
setShowAdd(true)}
- className="mt-3 flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
+ size="xs"
+ className="mt-3"
>
Add Tool
-
+
) : (
@@ -563,22 +565,26 @@ function ToolsTab({
)}
- toggleConnect(tool.id)}
- className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
+ className={
tool.connected
- ? "bg-green-500/10 text-green-600"
+ ? "bg-success/10 text-success"
: "bg-muted text-muted-foreground hover:bg-accent"
- }`}
+ }
>
{tool.connected ? "Connected" : "Connect"}
-
-
+ removeTool(tool.id)}
- className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-red-500"
+ className="text-muted-foreground hover:text-destructive"
>
-
+
))}
@@ -661,14 +667,14 @@ function TriggersTab({
{isDirty && (
-
{saving ? "Saving..." : "Save"}
-
+
)}
@@ -710,22 +716,24 @@ function TriggersTab({
}`}
/>
- removeTrigger(trigger.id)}
- className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-red-500"
+ className="text-muted-foreground hover:text-destructive"
>
-
+
{trigger.type === "scheduled" && (
@@ -762,20 +770,24 @@ function TriggersTab({
- addTrigger("on_assign")}
- className="flex items-center gap-1.5 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
+ className="border-dashed text-muted-foreground hover:text-foreground"
>
Add On Assign
-
-
+ addTrigger("scheduled")}
- className="flex items-center gap-1.5 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
+ className="border-dashed text-muted-foreground hover:text-foreground"
>
Add Scheduled
-
+
);
@@ -909,12 +921,13 @@ function AgentDetail({
)}
-
setShowMenu(!showMenu)}
- className="rounded-md p-1.5 hover:bg-accent"
>
-
+
{showMenu && (
<>
setShowMenu(false)} />
@@ -924,7 +937,7 @@ function AgentDetail({
setShowMenu(false);
setConfirmDelete(true);
}}
- className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive hover:bg-accent"
>
Delete Agent
@@ -978,15 +991,11 @@ function AgentDetail({
{/* Delete Confirmation */}
{confirmDelete && (
- <>
-
setConfirmDelete(false)}
- />
-
+
{ if (!v) setConfirmDelete(false); }}>
+
-
-
+
Delete agent?
@@ -995,25 +1004,22 @@ function AgentDetail({
-
- setConfirmDelete(false)}
- className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
- >
+
+ setConfirmDelete(false)}>
Cancel
-
-
+ {
setConfirmDelete(false);
onDelete(agent.id);
}}
- className="rounded-md bg-red-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-600"
>
Delete
-
-
-
- >
+
+
+
+
)}
);
@@ -1090,24 +1096,26 @@ export default function AgentsPage() {
Agents
-
setShowCreate(true)}
- className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-accent"
>
-
+
{agents.length === 0 ? (
No agents yet
-
setShowCreate(true)}
- className="mt-3 flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
+ size="xs"
+ className="mt-3"
>
Create Agent
-
+
) : (
@@ -1136,13 +1144,14 @@ export default function AgentsPage() {
Select an agent to view details
-
setShowCreate(true)}
- className="mt-3 flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
+ size="xs"
+ className="mt-3"
>
Create Agent
-
+
)}
diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx
index 8b729d7d..ca3641dd 100644
--- a/apps/web/app/(dashboard)/inbox/page.tsx
+++ b/apps/web/app/(dashboard)/inbox/page.tsx
@@ -11,6 +11,7 @@ import {
ArrowRightLeft,
} from "lucide-react";
import type { InboxItem, InboxItemType, InboxSeverity, InboxNewPayload } from "@multica/types";
+import { Button } from "@multica/ui/components/ui/button";
import { api } from "../../../lib/api";
import { useWSEvent } from "../../../lib/ws-context";
@@ -34,8 +35,8 @@ const typeIcons: Record
= {
};
const severityColors: Record = {
- action_required: "text-red-500",
- attention: "text-yellow-500",
+ action_required: "text-destructive",
+ attention: "text-warning",
info: "text-muted-foreground",
};
@@ -124,12 +125,14 @@ function InboxDetail({
{!item.read && (
-
onMarkRead(item.id)}
- className="shrink-0 rounded-md border px-2 py-1 text-xs hover:bg-accent"
+ className="shrink-0"
>
Mark read
-
+
)}
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx
index c6789ed1..ccc9bae4 100644
--- a/apps/web/app/(dashboard)/issues/[id]/page.tsx
+++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx
@@ -4,7 +4,6 @@ import { use, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
- Bot,
ChevronRight,
GitBranch,
Link2,
@@ -31,6 +30,9 @@ import {
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
+import { Button } from "@multica/ui/components/ui/button";
+import { Input } from "@multica/ui/components/ui/input";
+import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
import { StatusPicker, PriorityPicker, AssigneePicker } from "../_components";
import { api } from "../../../../lib/api";
@@ -62,42 +64,6 @@ function shortDate(date: string | null): string {
});
}
-// ---------------------------------------------------------------------------
-// Avatar
-// ---------------------------------------------------------------------------
-
-function ActorAvatar({
- actorType,
- actorId,
- size = 20,
-}: {
- actorType: string;
- actorId: string;
- size?: number;
-}) {
- const { getActorName, getActorInitials } = useAuth();
- const name = getActorName(actorType, actorId);
- const initials = getActorInitials(actorType, actorId);
- const isAgent = actorType === "agent";
- return (
-
- {isAgent ? (
-
- ) : (
- initials
- )}
-
- );
-}
-
// ---------------------------------------------------------------------------
// Property row
// ---------------------------------------------------------------------------
@@ -138,7 +104,7 @@ function DueDatePicker({
{date ? (
-
+
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
) : (
@@ -156,15 +122,17 @@ function DueDatePicker({
/>
{date && (
- {
onUpdate({ due_date: null });
setOpen(false);
}}
- className="text-xs text-muted-foreground hover:text-foreground"
+ className="text-muted-foreground hover:text-foreground"
>
Clear date
-
+
)}
@@ -207,12 +175,14 @@ function AcceptanceCriteriaEditor({
•
{item}
- removeItem(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
-
+
))}
@@ -268,18 +238,20 @@ function ContextRefsEditor({
{isUrl(ref) ? (
-
+
{ref}
) : (
{ref}
)}
-
removeRef(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
-
+
))}
@@ -358,41 +330,44 @@ function RepositoryEditor({
Repository
- setUrl(e.target.value)}
placeholder="https://github.com/org/repo"
- className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+ className="text-xs"
autoFocus
/>
- setBranch(e.target.value)}
placeholder="Branch"
- className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+ className="text-xs"
/>
- setPath(e.target.value)}
placeholder="Path"
- className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+ className="text-xs"
/>
{repository && (
-
Remove
-
+
)}
-
Save
-
+
@@ -410,7 +385,7 @@ export default function IssueDetailPage({
}) {
const { id } = use(params);
const router = useRouter();
- const { user, getActorName } = useAuth();
+ const { user, getActorName, getActorInitials } = useAuth();
const { updateTabTitle, activeTabId, closeTabByPath } = useTabStore();
const [issue, setIssue] = useState(null);
const [comments, setComments] = useState([]);
@@ -576,7 +551,7 @@ export default function IssueDetailPage({
}
+ render={}
>
@@ -644,6 +619,8 @@ export default function IssueDetailPage({
actorType={comment.author_type}
actorId={comment.author_id}
size={28}
+ getName={getActorName}
+ getInitials={getActorInitials}
/>
{getActorName(comment.author_type, comment.author_id)}
@@ -653,18 +630,22 @@ export default function IssueDetailPage({
{isOwn && (
-
startEditComment(comment)}
- className="p-1 text-muted-foreground hover:text-foreground rounded"
+ className="text-muted-foreground hover:text-foreground"
>
-
-
+ handleDeleteComment(comment.id)}
- className="p-1 text-muted-foreground hover:text-destructive rounded"
+ className="text-muted-foreground hover:text-destructive"
>
-
+
)}
@@ -691,20 +672,20 @@ export default function IssueDetailPage({
{/* Comment input */}
@@ -748,6 +729,8 @@ export default function IssueDetailPage({
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
+ getName={getActorName}
+ getInitials={getActorInitials}
/>
{getActorName(issue.creator_type, issue.creator_id)}
diff --git a/apps/web/app/(dashboard)/issues/_config/priority.ts b/apps/web/app/(dashboard)/issues/_config/priority.ts
index 59345ecd..202a61e3 100644
--- a/apps/web/app/(dashboard)/issues/_config/priority.ts
+++ b/apps/web/app/(dashboard)/issues/_config/priority.ts
@@ -12,9 +12,9 @@ export const PRIORITY_CONFIG: Record<
IssuePriority,
{ label: string; bars: number; color: string }
> = {
- urgent: { label: "Urgent", bars: 4, color: "text-orange-500" },
- high: { label: "High", bars: 3, color: "text-orange-400" },
- medium: { label: "Medium", bars: 2, color: "text-yellow-500" },
- low: { label: "Low", bars: 1, color: "text-blue-400" },
+ urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
+ high: { label: "High", bars: 3, color: "text-warning" },
+ medium: { label: "Medium", bars: 2, color: "text-warning" },
+ low: { label: "Low", bars: 1, color: "text-info" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
};
diff --git a/apps/web/app/(dashboard)/issues/_config/status.ts b/apps/web/app/(dashboard)/issues/_config/status.ts
index f00a7964..9e0fa809 100644
--- a/apps/web/app/(dashboard)/issues/_config/status.ts
+++ b/apps/web/app/(dashboard)/issues/_config/status.ts
@@ -24,9 +24,9 @@ export const STATUS_CONFIG: Record<
> = {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
- in_progress: { label: "In Progress", iconColor: "text-yellow-500", hoverBg: "hover:bg-yellow-500/10" },
- in_review: { label: "In Review", iconColor: "text-green-500", hoverBg: "hover:bg-green-500/10" },
- done: { label: "Done", iconColor: "text-blue-500", hoverBg: "hover:bg-blue-500/10" },
- blocked: { label: "Blocked", iconColor: "text-red-500", hoverBg: "hover:bg-red-500/10" },
+ in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
+ in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
+ done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
+ blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
};
diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx
index 4e3e5cae..7d3fab07 100644
--- a/apps/web/app/(dashboard)/issues/page.tsx
+++ b/apps/web/app/(dashboard)/issues/page.tsx
@@ -8,7 +8,6 @@ import {
Columns3,
List,
Plus,
- Bot,
} from "lucide-react";
import {
DndContext,
@@ -33,42 +32,23 @@ import {
DialogFooter,
DialogTrigger,
} from "@multica/ui/components/ui/dialog";
+import { Button } from "@multica/ui/components/ui/button";
+import { Input } from "@multica/ui/components/ui/input";
+import { Textarea } from "@multica/ui/components/ui/textarea";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@multica/ui/components/ui/select";
+import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import { StatusIcon, PriorityIcon } from "./_components";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
import { useWSEvent } from "../../../lib/ws-context";
import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types";
-function AssigneeAvatar({
- issue,
- size = "sm",
-}: {
- issue: Issue;
- size?: "sm" | "md";
-}) {
- const { getActorName, getActorInitials } = useAuth();
- if (!issue.assignee_type || !issue.assignee_id) return null;
- const name = getActorName(issue.assignee_type, issue.assignee_id);
- const initials = getActorInitials(issue.assignee_type, issue.assignee_id);
- const sizeClass = size === "sm" ? "h-5 w-5 text-[10px]" : "h-6 w-6 text-xs";
- return (
-
- {issue.assignee_type === "agent" ? (
-
- ) : (
- initials
- )}
-
- );
-}
-
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
@@ -81,6 +61,7 @@ function formatDate(date: string): string {
// ---------------------------------------------------------------------------
function BoardCardContent({ issue }: { issue: Issue }) {
+ const { getActorName, getActorInitials } = useAuth();
return (
@@ -90,7 +71,15 @@ function BoardCardContent({ issue }: { issue: Issue }) {
{issue.title}
-
+ {issue.assignee_type && issue.assignee_id && (
+
+ )}
{issue.due_date && (
@@ -276,6 +265,7 @@ function BoardView({
// ---------------------------------------------------------------------------
function ListRow({ issue }: { issue: Issue }) {
+ const { getActorName, getActorInitials } = useAuth();
return (
)}
-
+ {issue.assignee_type && issue.assignee_id && (
+
+ )}
);
}
@@ -374,10 +372,10 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
{ setOpen(v); if (!v) reset(); }}>
+
New Issue
-
+
}
/>
@@ -385,7 +383,7 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
New Issue
-
{submitting ? "Creating..." : "Create Issue"}
-
+
@@ -548,50 +542,56 @@ export default function IssuesPage() {
All Issues
- setView("board")}
- className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
+ className={
view === "board"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
- }`}
+ }
>
Board
-
-
+ setView("list")}
- className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
+ className={
view === "list"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
- }`}
+ }
>
List
-
+
-
-
+
+
diff --git a/apps/web/app/(dashboard)/knowledge-base/page.tsx b/apps/web/app/(dashboard)/knowledge-base/page.tsx
index 925d3238..a49168b4 100644
--- a/apps/web/app/(dashboard)/knowledge-base/page.tsx
+++ b/apps/web/app/(dashboard)/knowledge-base/page.tsx
@@ -7,6 +7,8 @@ import {
Search,
Link as LinkIcon,
} from "lucide-react";
+import { Input } from "@multica/ui/components/ui/input";
+import { Button } from "@multica/ui/components/ui/button";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -293,21 +295,21 @@ export default function KnowledgeBasePage() {
{/* Search */}
diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx
index af2a7ea1..dba1b245 100644
--- a/apps/web/app/(dashboard)/settings/page.tsx
+++ b/apps/web/app/(dashboard)/settings/page.tsx
@@ -3,6 +3,17 @@
import { useEffect, useState } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react";
import type { MemberWithUser, MemberRole } from "@multica/types";
+import { Input } from "@multica/ui/components/ui/input";
+import { Textarea } from "@multica/ui/components/ui/textarea";
+import { Label } from "@multica/ui/components/ui/label";
+import { Button } from "@multica/ui/components/ui/button";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@multica/ui/components/ui/select";
import { useAuth } from "../../../lib/auth-context";
import { api } from "../../../lib/api";
@@ -49,16 +60,14 @@ function MemberRow({
{member.email}
{canEditRole ? (
-
+
) : (
@@ -66,14 +75,15 @@ function MemberRow({
)}
{canRemove && (
-
-
+
)}
);
@@ -256,43 +266,43 @@ export default function SettingsPage() {
@@ -306,53 +316,53 @@ export default function SettingsPage() {
-
-
+
Description
-
-
-
+
Slug
-
+
{workspace.slug}
{workspaceError && (
- {workspaceError}
+ {workspaceError}
)}
{saved && (
- Saved!
+ Saved!
)}
-
{saving ? "Saving..." : "Save"}
-
+
{!canManageWorkspace && (
@@ -374,7 +384,7 @@ export default function SettingsPage() {
{memberError && (
-
{memberError}
+
{memberError}
)}
{canManageWorkspace && (
@@ -384,29 +394,26 @@ export default function SettingsPage() {
Add member
- setInviteEmail(e.target.value)}
placeholder="user@company.com"
- className="rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
-
- setInviteRole(value as MemberRole)}>
+
+
+ Member
+ Admin
+ {isOwner && Owner}
+
+
+
{inviteLoading ? "Adding..." : "Add"}
-
+
)}
@@ -444,30 +451,32 @@ export default function SettingsPage() {
Remove yourself from this workspace.
-
{memberActionId === "leave" ? "Leaving..." : "Leave workspace"}
-
+
{isOwner && (
-
Delete workspace
+
Delete workspace
Permanently delete this workspace and its data.
-
{memberActionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
-
+
)}
diff --git a/apps/web/app/pair/local/page.tsx b/apps/web/app/pair/local/page.tsx
index 023496d9..8dc70471 100644
--- a/apps/web/app/pair/local/page.tsx
+++ b/apps/web/app/pair/local/page.tsx
@@ -4,6 +4,15 @@ import Link from "next/link";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { DaemonPairingSession } from "@multica/types";
+import { Button } from "@multica/ui/components/ui/button";
+import { Label } from "@multica/ui/components/ui/label";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@multica/ui/components/ui/select";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
@@ -77,7 +86,7 @@ function LocalDaemonPairPageContent() {
{loading || isLoading ? (
Loading pairing session...
) : error ? (
-
+
{error}
) : session ? (
@@ -109,11 +118,11 @@ function LocalDaemonPairPageContent() {
) : 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 ? (
@@ -123,28 +132,28 @@ function LocalDaemonPairPageContent() {
) : (
- Workspace
-
+ Workspace
+
-
{submitting ? "Registering..." : "Register runtime"}
-
+
)}
>
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 17fd3459..4f329977 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -9,6 +9,7 @@
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./components/ui/*": "./src/components/ui/*.tsx",
+ "./components/common/*": "./src/components/common/*.tsx",
"./components/markdown": "./src/components/markdown/index.ts",
"./hooks/*": "./src/hooks/*.ts"
},
diff --git a/packages/ui/src/components/common/actor-avatar.tsx b/packages/ui/src/components/common/actor-avatar.tsx
new file mode 100644
index 00000000..a5ced65f
--- /dev/null
+++ b/packages/ui/src/components/common/actor-avatar.tsx
@@ -0,0 +1,44 @@
+import { Bot } from "lucide-react";
+import { cn } from "@multica/ui/lib/utils";
+
+interface ActorAvatarProps {
+ actorType: string;
+ actorId: string;
+ size?: number;
+ getName?: (type: string, id: string) => string;
+ getInitials?: (type: string, id: string) => string;
+ className?: string;
+}
+
+function ActorAvatar({
+ actorType,
+ actorId,
+ size = 20,
+ getName,
+ getInitials,
+ className,
+}: ActorAvatarProps) {
+ const name = getName?.(actorType, actorId);
+ const initials = getInitials?.(actorType, actorId);
+ const isAgent = actorType === "agent";
+
+ return (
+
+ {isAgent ? (
+
+ ) : (
+ initials
+ )}
+
+ );
+}
+
+export { ActorAvatar, type ActorAvatarProps };
diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css
index b44eca0c..5c64488a 100644
--- a/packages/ui/src/styles/globals.css
+++ b/packages/ui/src/styles/globals.css
@@ -104,6 +104,9 @@
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
+ --color-success: var(--success);
+ --color-warning: var(--warning);
+ --color-info: var(--info);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@@ -175,6 +178,11 @@
--tool-running: oklch(0.6 0.18 250); /* Blue: active/in-progress */
--tool-success: oklch(0.72 0.12 145); /* Green: completed */
--tool-error: oklch(0.65 0.2 25); /* Red: failed */
+
+ /* Semantic status colors — for general UI (badges, alerts, indicators) */
+ --success: oklch(0.55 0.16 145); /* Green: saved, completed, online */
+ --warning: oklch(0.75 0.16 85); /* Yellow/amber: blocked, attention */
+ --info: oklch(0.55 0.18 250); /* Blue: links, dispatched, done */
}
/* =========================================================================
@@ -221,6 +229,10 @@
--tool-running: oklch(0.65 0.2 250);
--tool-success: oklch(0.65 0.15 145);
--tool-error: oklch(0.7 0.2 22);
+
+ --success: oklch(0.65 0.15 145);
+ --warning: oklch(0.70 0.16 85);
+ --info: oklch(0.65 0.18 250);
}
/* Shiki dual themes: CSS-only light/dark switching via CSS variables */
From 2c28c4cba271a4671e283c735f9ec8cc2ff65ce7 Mon Sep 17 00:00:00 2001
From: Jiayuan Zhang
Date: Tue, 24 Mar 2026 14:27:35 +0800
Subject: [PATCH 4/9] refactor(dev): share postgres across main and worktrees
---
.env.example | 1 -
CLAUDE.md | 13 +-
LOCAL_DEVELOPMENT.md | 471 ++++++++++++++++++++++++
Makefile | 61 +--
README.md | 40 +-
docker-compose.yml | 6 +-
e2e/comments.spec.ts | 19 +-
e2e/fixtures.ts | 36 +-
e2e/helpers.ts | 26 +-
e2e/issues.spec.ts | 2 +-
scripts/check.sh | 29 +-
scripts/ensure-postgres.sh | 42 +++
scripts/init-worktree-env.sh | 13 +-
server/cmd/seed/main.go | 221 -----------
server/cmd/server/integration_test.go | 111 +++++-
server/internal/handler/handler_test.go | 107 ++++--
16 files changed, 839 insertions(+), 359 deletions(-)
create mode 100644 LOCAL_DEVELOPMENT.md
create mode 100644 scripts/ensure-postgres.sh
delete mode 100644 server/cmd/seed/main.go
diff --git a/.env.example b/.env.example
index 3c410d41..312ec87a 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,4 @@
# Database
-COMPOSE_PROJECT_NAME=super_multica
POSTGRES_DB=multica
POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
diff --git a/CLAUDE.md b/CLAUDE.md
index 57c1342a..10776878 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,10 +22,10 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
```bash
# One-click setup & run
-make setup # First-time: install deps, start DB, migrate
-make seed # Optional: load example data
+make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
-make stop # Stop everything
+make stop # Stop app processes for the current checkout
+make db-down # Stop the shared PostgreSQL container
# Frontend
pnpm install
@@ -41,11 +41,10 @@ make test # Go tests
make sqlc # Regenerate sqlc code
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
-make seed # Seed example data
# Infrastructure
-docker compose up -d # Start PostgreSQL
-docker compose down # Stop PostgreSQL
+make db-up # Start shared PostgreSQL
+make db-down # Stop shared PostgreSQL
```
## 4. Coding Rules
@@ -69,7 +68,7 @@ docker compose down # Stop PostgreSQL
## 6. Testing Rules
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
-- **Go**: Standard `go test`. Use testcontainers or test database for DB tests.
+- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
## 7. Commit Rules
diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md
new file mode 100644
index 00000000..b93af57b
--- /dev/null
+++ b/LOCAL_DEVELOPMENT.md
@@ -0,0 +1,471 @@
+# Local Development Guide
+
+This guide documents the intended local development workflow for Multica.
+
+It covers:
+
+- first-time setup
+- day-to-day development in the main checkout
+- isolated worktree development
+- the shared PostgreSQL model
+- testing and verification
+- troubleshooting and destructive reset options
+
+## Development Model
+
+Local development uses one shared PostgreSQL container and one database per checkout.
+
+- the main checkout usually uses `.env` and `POSTGRES_DB=multica`
+- each Git worktree uses its own `.env.worktree`
+- every checkout connects to the same PostgreSQL host: `localhost:5432`
+- isolation happens at the database level, not by starting a separate Docker Compose project
+- backend and frontend ports are still unique per worktree
+
+This keeps Docker simple while still isolating schema and data.
+
+## Prerequisites
+
+- Node.js `v20+`
+- `pnpm` `v10.28+`
+- Go `v1.26+`
+- Docker
+
+## Important Rules
+
+- The main checkout should use `.env`.
+- A worktree should use `.env.worktree`.
+- Do not copy `.env` into a worktree directory.
+
+Why:
+
+- the current command flow prefers `.env` over `.env.worktree`
+- if a worktree contains `.env`, it can accidentally point back to the main database
+
+## Environment Files
+
+### Main Checkout
+
+Create `.env` once:
+
+```bash
+cp .env.example .env
+```
+
+By default, `.env` points to:
+
+```bash
+POSTGRES_DB=multica
+POSTGRES_PORT=5432
+DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
+PORT=8080
+FRONTEND_PORT=3000
+```
+
+### Worktree
+
+Generate `.env.worktree` from inside the worktree:
+
+```bash
+make worktree-env
+```
+
+That generates values like:
+
+```bash
+POSTGRES_DB=multica_super_multica_702
+POSTGRES_PORT=5432
+PORT=18782
+FRONTEND_PORT=13702
+DATABASE_URL=postgres://multica:multica@localhost:5432/multica_super_multica_702?sslmode=disable
+```
+
+Notes:
+
+- `POSTGRES_DB` is unique per worktree
+- `POSTGRES_PORT` stays fixed at `5432`
+- backend and frontend ports are derived from the worktree path hash
+- `make worktree-env` refuses to overwrite an existing `.env.worktree`
+
+To regenerate a worktree env file:
+
+```bash
+FORCE=1 make worktree-env
+```
+
+## First-Time Setup
+
+### Main Checkout
+
+From the main checkout:
+
+```bash
+cp .env.example .env
+make setup-main
+```
+
+What `make setup-main` does:
+
+- installs JavaScript dependencies with `pnpm install`
+- ensures the shared PostgreSQL container is running
+- creates the application database if it does not exist
+- runs all migrations against that database
+
+Start the app:
+
+```bash
+make start-main
+```
+
+Stop the app processes:
+
+```bash
+make stop-main
+```
+
+This does not stop PostgreSQL.
+
+### Worktree
+
+From the worktree directory:
+
+```bash
+make worktree-env
+make setup-worktree
+```
+
+What `make setup-worktree` does:
+
+- uses `.env.worktree`
+- ensures the shared PostgreSQL container is running
+- creates the worktree database if it does not exist
+- runs migrations against the worktree database
+
+Start the worktree app:
+
+```bash
+make start-worktree
+```
+
+Stop the worktree app processes:
+
+```bash
+make stop-worktree
+```
+
+## Recommended Daily Workflow
+
+### Main Checkout
+
+Use the main checkout when you want a stable local environment for `main`.
+
+```bash
+make start-main
+make stop-main
+make check-main
+```
+
+### Feature Worktree
+
+Use a worktree when you want isolated data and separate app ports.
+
+```bash
+git worktree add ../super-multica-feature -b feat/my-change main
+cd ../super-multica-feature
+make worktree-env
+make setup-worktree
+make start-worktree
+```
+
+After that, day-to-day commands are:
+
+```bash
+make start-worktree
+make stop-worktree
+make check-worktree
+```
+
+## Running Main and Worktree at the Same Time
+
+This is a first-class workflow.
+
+Example:
+
+- main checkout
+ - database: `multica`
+ - backend: `8080`
+ - frontend: `3000`
+- worktree checkout
+ - database: `multica_super_multica_702`
+ - backend: generated worktree port such as `18782`
+ - frontend: generated worktree port such as `13702`
+
+Both checkouts use:
+
+- the same PostgreSQL container
+- the same PostgreSQL port: `5432`
+
+But they do not share application data, because each uses a different database.
+
+## Command Reference
+
+### Shared Infrastructure
+
+Start the shared PostgreSQL container:
+
+```bash
+make db-up
+```
+
+Stop the shared PostgreSQL container:
+
+```bash
+make db-down
+```
+
+Important:
+
+- `make db-down` stops the container but keeps the Docker volume
+- your local databases are preserved
+
+### App Lifecycle
+
+Main checkout:
+
+```bash
+make setup-main
+make start-main
+make stop-main
+make check-main
+```
+
+Worktree:
+
+```bash
+make worktree-env
+make setup-worktree
+make start-worktree
+make stop-worktree
+make check-worktree
+```
+
+Generic targets for the current checkout:
+
+```bash
+make setup
+make start
+make stop
+make check
+make dev
+make test
+make migrate-up
+make migrate-down
+```
+
+These generic targets require a valid env file in the current directory.
+
+## How Database Creation Works
+
+Database creation is automatic.
+
+The following commands all ensure the target database exists before they continue:
+
+- `make setup`
+- `make start`
+- `make dev`
+- `make test`
+- `make migrate-up`
+- `make migrate-down`
+- `make check`
+
+That logic lives in `scripts/ensure-postgres.sh`.
+
+## Testing
+
+Run all local checks:
+
+```bash
+make check-main
+```
+
+Or from a worktree:
+
+```bash
+make check-worktree
+```
+
+This runs:
+
+1. TypeScript typecheck
+2. TypeScript unit tests
+3. Go tests
+4. Playwright E2E tests
+
+Notes:
+
+- Go tests create their own fixture data
+- E2E tests create their own workspace and issue fixtures
+- the check flow starts backend/frontend only if they are not already running
+
+## Local Codex Daemon
+
+Run the local daemon:
+
+```bash
+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
+
+## Troubleshooting
+
+### Missing Env File
+
+If you see:
+
+```text
+Missing env file: .env
+```
+
+or:
+
+```text
+Missing env file: .env.worktree
+```
+
+then create the expected env file first.
+
+Main checkout:
+
+```bash
+cp .env.example .env
+```
+
+Worktree:
+
+```bash
+make worktree-env
+```
+
+### Check Which Database a Checkout Uses
+
+Inspect the env file:
+
+```bash
+cat .env
+cat .env.worktree
+```
+
+Look for:
+
+- `POSTGRES_DB`
+- `DATABASE_URL`
+- `PORT`
+- `FRONTEND_PORT`
+
+### List All Local Databases in Shared PostgreSQL
+
+```bash
+docker compose exec -T postgres psql -U multica -d postgres -At -c "select datname from pg_database order by datname;"
+```
+
+### Worktree Is Accidentally Using the Main Database
+
+Check whether the worktree contains `.env`.
+
+It should not.
+
+The safe worktree setup is:
+
+```bash
+make worktree-env
+make setup-worktree
+make start-worktree
+```
+
+### App Stops but PostgreSQL Keeps Running
+
+That is expected.
+
+- `make stop`
+- `make stop-main`
+- `make stop-worktree`
+
+only stop backend/frontend processes.
+
+To stop the shared PostgreSQL container:
+
+```bash
+make db-down
+```
+
+## Destructive Reset
+
+If you want to stop PostgreSQL and keep your local databases:
+
+```bash
+make db-down
+```
+
+If you want to wipe all local PostgreSQL data for this repo:
+
+```bash
+docker compose down -v
+```
+
+Warning:
+
+- this deletes the shared Docker volume
+- this deletes the main database and every worktree database in that volume
+- after that you must run `make setup-main` or `make setup-worktree` again
+
+## Typical Flows
+
+### Stable Main Environment
+
+```bash
+cp .env.example .env
+make setup-main
+make start-main
+```
+
+### Feature Worktree
+
+```bash
+git worktree add ../super-multica-feature -b feat/my-change main
+cd ../super-multica-feature
+make worktree-env
+make setup-worktree
+make start-worktree
+```
+
+### Return to a Previously Configured Worktree
+
+```bash
+cd ../super-multica-feature
+make start-worktree
+```
+
+### Validate Before Pushing
+
+Main checkout:
+
+```bash
+make check-main
+```
+
+Worktree:
+
+```bash
+make check-worktree
+```
diff --git a/Makefile b/Makefile
index 9fe220c2..f9deb94d 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree
+.PHONY: dev daemon build test migrate-up migrate-down sqlc clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -20,47 +20,40 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
-COMPOSE_PROJECT_NAME ?= super_multica
export
-COMPOSE := docker compose --env-file $(ENV_FILE)
+COMPOSE := docker compose
+
+define REQUIRE_ENV
+ @if [ ! -f "$(ENV_FILE)" ]; then \
+ echo "Missing env file: $(ENV_FILE)"; \
+ echo "Create .env from .env.example, or run 'make worktree-env' and use .env.worktree."; \
+ exit 1; \
+ fi
+endef
# ---------- One-click commands ----------
# First-time setup: install deps, start DB, run migrations
setup:
+ $(REQUIRE_ENV)
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
pnpm install
- @echo "==> Starting PostgreSQL..."
- @if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \
- echo " PostgreSQL already running, skipping docker compose up."; \
- else \
- $(COMPOSE) up -d; \
- echo "==> Waiting for PostgreSQL to be ready..."; \
- until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \
- sleep 1; \
- done; \
- fi
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo ""
- @echo "✓ Setup complete! Run 'make seed' if you want example data, then 'make start' to launch the app."
+ @echo "✓ Setup complete! Run 'make start' to launch the app."
# Start all services (backend + frontend)
start:
+ $(REQUIRE_ENV)
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
- @if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \
- echo "PostgreSQL already running, skipping docker compose up."; \
- else \
- $(COMPOSE) up -d; \
- until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \
- sleep 1; \
- done; \
- fi
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "Starting backend and frontend..."
@trap 'kill 0' EXIT; \
(cd server && go run ./cmd/server) & \
@@ -69,15 +62,22 @@ start:
# Stop all services
stop:
+ $(REQUIRE_ENV)
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
- $(COMPOSE) down
- @echo "✓ All services stopped."
+ @echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
# Full verification: typecheck + unit tests + Go tests + E2E
check:
- @bash scripts/check.sh
+ $(REQUIRE_ENV)
+ @ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
+
+db-up:
+ @$(COMPOSE) up -d postgres
+
+db-down:
+ @$(COMPOSE) down
worktree-env:
@bash scripts/init-worktree-env.sh .env.worktree
@@ -110,6 +110,8 @@ check-worktree:
# Go server
dev:
+ $(REQUIRE_ENV)
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
daemon:
@@ -120,21 +122,24 @@ build:
cd server && go build -o bin/daemon ./cmd/daemon
test:
+ $(REQUIRE_ENV)
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go test ./...
# Database
migrate-up:
+ $(REQUIRE_ENV)
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
migrate-down:
+ $(REQUIRE_ENV)
+ @bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate down
sqlc:
cd server && sqlc generate
-seed:
- cd server && go run ./cmd/seed
-
# Cleanup
clean:
rm -rf server/bin server/tmp
diff --git a/README.md b/README.md
index e821dbf8..eec73dcf 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
AI-native task management platform — like Linear, but with AI agents as first-class citizens.
+For the full local development workflow, see [Local Development Guide](LOCAL_DEVELOPMENT.md).
+
## Prerequisites
- [Node.js](https://nodejs.org/) (v20+)
@@ -18,19 +20,16 @@ pnpm install
# 2. Copy environment variables for the shared main environment
cp .env.example .env
-# 3. One-time setup: start DB and run migrations
+# 3. One-time setup: ensure shared PostgreSQL, create the app DB, run migrations
make setup
-# 4. Optional: load example data
-make seed
-
-# 5. Start backend + frontend
+# 4. Start backend + frontend
make start
```
Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000).
-Default behavior now prefers the shared main environment in `.env`. If you want an isolated environment for a Git worktree, generate `.env.worktree` and use the explicit worktree targets:
+Main checkout uses `.env`. A Git worktree should generate its own `.env.worktree` and use the explicit worktree targets:
```bash
make worktree-env
@@ -38,13 +37,19 @@ make setup-worktree
make start-worktree
```
-This lets you keep `.env` connected to your main database while using `.env.worktree` only for isolated feature testing.
+Every checkout shares the same PostgreSQL container on `localhost:5432`. Isolation now happens at the database level:
+
+- `.env` typically uses `POSTGRES_DB=multica`
+- each `.env.worktree` gets its own `POSTGRES_DB`, such as `multica_super_multica_702`
+- backend/frontend ports still stay unique per worktree
+
+That keeps one Docker container and one volume, while still isolating schema and data per worktree.
## Project Structure
```
├── server/ # Go backend (Chi + sqlc + gorilla/websocket)
-│ ├── cmd/ # server, daemon, migrate, seed
+│ ├── cmd/ # server, daemon, migrate
│ ├── internal/ # Core business logic
│ ├── migrations/ # SQL migrations
│ └── sqlc.yaml # sqlc config
@@ -87,11 +92,10 @@ This lets you keep `.env` connected to your main database while using `.env.work
| Command | Description |
|---------|-------------|
-| `docker compose up -d` | Start PostgreSQL |
-| `docker compose down` | Stop PostgreSQL |
-| `make migrate-up` | Run database migrations |
-| `make migrate-down` | Rollback database migrations |
-| `make seed` | Seed example data |
+| `make db-up` | Start the shared PostgreSQL container |
+| `make db-down` | Stop the shared PostgreSQL container |
+| `make migrate-up` | Ensure the current DB exists, then run migrations |
+| `make migrate-down` | Rollback database migrations for the current DB |
| `make worktree-env` | Generate an isolated `.env.worktree` for the current worktree |
| `make setup-main` / `make start-main` | Force use of the shared main `.env` |
| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` |
@@ -101,8 +105,8 @@ This lets you keep `.env` connected to your main database while using `.env.work
See [`.env.example`](.env.example) for all available variables:
- `DATABASE_URL` — PostgreSQL connection string
-- `COMPOSE_PROJECT_NAME` — Docker Compose project name
-- `POSTGRES_DB` / `POSTGRES_PORT` — Per-worktree PostgreSQL database and host port
+- `POSTGRES_DB` — Database name for the current checkout or worktree
+- `POSTGRES_PORT` — Shared PostgreSQL host port (fixed to `5432`)
- `PORT` — Backend server port (default: 8080)
- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
- `JWT_SECRET` — JWT signing secret
@@ -130,3 +134,9 @@ The local daemon currently supports one local runtime type: `codex`.
8. The daemon claims the task, runs `codex exec`, and reports the final comment back to the issue.
For local development you can still set `MULTICA_WORKSPACE_ID` directly to skip pairing, but that should be treated as a debug shortcut rather than the normal flow.
+
+## Local Development Notes
+
+- `make setup`, `make start`, `make dev`, and `make test` now require an env file. They fail fast if `.env` or `.env.worktree` is missing.
+- `make stop` only stops the backend/frontend processes for the current checkout. It does not stop the shared PostgreSQL container.
+- Use `make db-down` only when you explicitly want to shut down the shared local PostgreSQL instance for every checkout.
diff --git a/docker-compose.yml b/docker-compose.yml
index 21b005f3..0c4b21b1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,12 +1,14 @@
+name: super_multica
+
services:
postgres:
image: pgvector/pgvector:pg17
environment:
- POSTGRES_DB: ${POSTGRES_DB:-multica}
+ POSTGRES_DB: multica
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- - "${POSTGRES_PORT:-5432}:5432"
+ - "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
diff --git a/e2e/comments.spec.ts b/e2e/comments.spec.ts
index d33107fd..68f55251 100644
--- a/e2e/comments.spec.ts
+++ b/e2e/comments.spec.ts
@@ -1,10 +1,21 @@
import { test, expect } from "@playwright/test";
-import { loginAsDefault } from "./helpers";
+import { createTestApi, loginAsDefault } from "./helpers";
+import type { TestApiClient } from "./fixtures";
test.describe("Comments", () => {
- test("can add a comment on an issue", async ({ page }) => {
- await loginAsDefault(page);
+ let api: TestApiClient;
+ test.beforeEach(async ({ page }) => {
+ api = await createTestApi();
+ await api.createIssue("E2E Comment Test " + Date.now());
+ await loginAsDefault(page);
+ });
+
+ test.afterEach(async () => {
+ await api.cleanup();
+ });
+
+ test("can add a comment on an issue", async ({ page }) => {
// Wait for issues to load and click first one
const issueLink = page.locator('a[href^="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
@@ -31,8 +42,6 @@ test.describe("Comments", () => {
});
test("comment submit button is disabled when empty", async ({ page }) => {
- await loginAsDefault(page);
-
const issueLink = page.locator('a[href^="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts
index 4c3b2a09..4e153ccf 100644
--- a/e2e/fixtures.ts
+++ b/e2e/fixtures.ts
@@ -7,6 +7,12 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
+interface TestWorkspace {
+ id: string;
+ name: string;
+ slug: string;
+}
+
export class TestApiClient {
private token: string | null = null;
private workspaceId: string | null = null;
@@ -23,7 +29,7 @@ export class TestApiClient {
return data;
}
- async getWorkspaces() {
+ async getWorkspaces(): Promise {
const res = await this.authedFetch("/api/workspaces");
return res.json();
}
@@ -32,6 +38,34 @@ export class TestApiClient {
this.workspaceId = id;
}
+ async ensureWorkspace(name = "E2E Workspace", slug = "e2e-workspace") {
+ const workspaces = await this.getWorkspaces();
+ const workspace = workspaces.find((item) => item.slug === slug) ?? workspaces[0];
+ if (workspace) {
+ this.workspaceId = workspace.id;
+ return workspace;
+ }
+
+ const res = await this.authedFetch("/api/workspaces", {
+ method: "POST",
+ body: JSON.stringify({ name, slug }),
+ });
+ if (res.ok) {
+ const created = (await res.json()) as TestWorkspace;
+ this.workspaceId = created.id;
+ return created;
+ }
+
+ const refreshed = await this.getWorkspaces();
+ const created = refreshed.find((item) => item.slug === slug) ?? refreshed[0];
+ if (created) {
+ this.workspaceId = created.id;
+ return created;
+ }
+
+ throw new Error(`Failed to ensure workspace ${slug}: ${res.status} ${res.statusText}`);
+ }
+
async createIssue(title: string, opts?: Record) {
const res = await this.authedFetch("/api/issues", {
method: "POST",
diff --git a/e2e/helpers.ts b/e2e/helpers.ts
index 150ddd0e..3cc93d93 100644
--- a/e2e/helpers.ts
+++ b/e2e/helpers.ts
@@ -1,31 +1,33 @@
import { type Page } from "@playwright/test";
import { TestApiClient } from "./fixtures";
+const DEFAULT_E2E_NAME = "E2E User";
+const DEFAULT_E2E_EMAIL = "e2e@multica.ai";
+const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
+
/**
- * Login as the seeded user (has workspace and issues).
+ * Log in as the default E2E user and ensure the workspace exists first.
*/
export async function loginAsDefault(page: Page) {
+ const api = new TestApiClient();
+ await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
+ await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
+
await page.goto("/login");
- await page.fill('input[placeholder="Name"]', "Jiayuan Zhang");
- await page.fill('input[placeholder="Email"]', "jiayuan@multica.ai");
+ await page.fill('input[placeholder="Name"]', DEFAULT_E2E_NAME);
+ await page.fill('input[placeholder="Email"]', DEFAULT_E2E_EMAIL);
await page.click('button[type="submit"]');
await page.waitForURL("**/issues", { timeout: 10000 });
}
/**
- * Open the workspace switcher dropdown menu.
- */
-/**
- * Create a TestApiClient logged in as the default seeded user.
+ * Create a TestApiClient logged in as the default E2E user.
* Call api.cleanup() in afterEach to remove test data created during the test.
*/
export async function createTestApi(): Promise {
const api = new TestApiClient();
- await api.login("jiayuan@multica.ai", "Jiayuan Zhang");
- const workspaces = await api.getWorkspaces();
- if (workspaces.length > 0) {
- api.setWorkspaceId(workspaces[0].id);
- }
+ await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
+ await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
return api;
}
diff --git a/e2e/issues.spec.ts b/e2e/issues.spec.ts
index 1feeb7c0..2f9403c4 100644
--- a/e2e/issues.spec.ts
+++ b/e2e/issues.spec.ts
@@ -49,7 +49,7 @@ test.describe("Issues", () => {
});
test("can navigate to issue detail page", async ({ page }) => {
- // Create a known issue via API so we don't depend on seed data
+ // Create a known issue via API so the test controls its own fixture
const issue = await api.createIssue("E2E Detail Test " + Date.now());
// Reload to see the new issue
diff --git a/scripts/check.sh b/scripts/check.sh
index ef4af067..3c11e026 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -6,14 +6,18 @@ set -euo pipefail
# Usage: bash scripts/check.sh
# ==========================================================================
-ENV_FILE="${ENV_FILE:-$(if [ -f .env ]; then echo .env; elif [ -f .env.worktree ]; then echo .env.worktree; else echo .env; fi)}"
-if [ -f "$ENV_FILE" ]; then
- set -a
- # shellcheck disable=SC1090
- . "$ENV_FILE"
- set +a
+ENV_FILE="${ENV_FILE:-.env}"
+if [ ! -f "$ENV_FILE" ]; then
+ echo "Missing env file: $ENV_FILE"
+ echo "Create .env from .env.example, or run 'make worktree-env' and use .env.worktree."
+ exit 1
fi
+set -a
+# shellcheck disable=SC1090
+. "$ENV_FILE"
+set +a
+
POSTGRES_DB="${POSTGRES_DB:-multica}"
POSTGRES_USER="${POSTGRES_USER:-multica}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
@@ -22,8 +26,6 @@ FRONTEND_PORT="${FRONTEND_PORT:-3000}"
PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-http://localhost:${FRONTEND_PORT}}"
export PLAYWRIGHT_BASE_URL
-COMPOSE_CMD=(docker compose --env-file "$ENV_FILE")
-
BACKEND_PID=""
FRONTEND_PID=""
STARTED_BACKEND=false
@@ -77,16 +79,7 @@ wait_for_port() {
# --------------------------------------------------------------------------
echo "==> Using env file: $ENV_FILE"
echo "==> Checking PostgreSQL..."
-if pg_isready -h localhost -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then
- echo " Already running."
-else
- echo " Starting via docker compose..."
- "${COMPOSE_CMD[@]}" up -d
- until "${COMPOSE_CMD[@]}" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do
- sleep 1
- done
- echo " PostgreSQL ready."
-fi
+bash scripts/ensure-postgres.sh "$ENV_FILE"
# --------------------------------------------------------------------------
# Step 1: TypeScript typecheck
diff --git a/scripts/ensure-postgres.sh b/scripts/ensure-postgres.sh
new file mode 100644
index 00000000..78d7345b
--- /dev/null
+++ b/scripts/ensure-postgres.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ENV_FILE="${1:-.env}"
+
+if [ ! -f "$ENV_FILE" ]; then
+ echo "Missing env file: $ENV_FILE"
+ echo "Create .env from .env.example, or run 'make worktree-env' and use .env.worktree."
+ exit 1
+fi
+
+set -a
+# shellcheck disable=SC1090
+. "$ENV_FILE"
+set +a
+
+POSTGRES_DB="${POSTGRES_DB:-multica}"
+POSTGRES_USER="${POSTGRES_USER:-multica}"
+POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-multica}"
+
+export PGPASSWORD="$POSTGRES_PASSWORD"
+
+echo "==> Ensuring shared PostgreSQL container is running on localhost:5432..."
+docker compose up -d postgres
+
+echo "==> Waiting for PostgreSQL to be ready..."
+until docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d postgres > /dev/null 2>&1; do
+ sleep 1
+done
+
+echo "==> Ensuring database '$POSTGRES_DB' exists..."
+db_exists="$(docker compose exec -T postgres \
+ psql -U "$POSTGRES_USER" -d postgres -Atqc "SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB'")"
+
+if [ "$db_exists" != "1" ]; then
+ docker compose exec -T postgres \
+ psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
+ -c "CREATE DATABASE \"$POSTGRES_DB\"" \
+ > /dev/null
+fi
+
+echo "✓ PostgreSQL ready. Application database: $POSTGRES_DB"
diff --git a/scripts/init-worktree-env.sh b/scripts/init-worktree-env.sh
index cf123bef..38b4db73 100644
--- a/scripts/init-worktree-env.sh
+++ b/scripts/init-worktree-env.sh
@@ -17,15 +17,13 @@ fi
hash_value="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
offset=$((hash_value % 1000))
-postgres_db="multica_${slug}"
-postgres_port=$((15432 + offset))
+postgres_db="multica_${slug}_${offset}"
+postgres_port=5432
backend_port=$((18080 + offset))
frontend_port=$((13000 + offset))
frontend_origin="http://localhost:${frontend_port}"
-compose_project_name="multica_${slug}_${offset}"
cat > "$ENV_FILE" <
Date: Tue, 24 Mar 2026 14:29:56 +0800
Subject: [PATCH 5/9] fix(ci): provision postgres for backend tests
---
.github/workflows/ci.yml | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 033c78a1..afe63e0e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,6 +43,22 @@ jobs:
backend:
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: pgvector/pgvector:pg17
+ env:
+ POSTGRES_DB: multica
+ POSTGRES_USER: multica
+ POSTGRES_PASSWORD: multica
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U multica -d multica"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 20
+ env:
+ DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -56,5 +72,8 @@ jobs:
- name: Build
run: cd server && go build ./...
+ - name: Run migrations
+ run: cd server && go run ./cmd/migrate up
+
- name: Test
run: cd server && go test ./...
From 4c6eb81789ab8f698ec6150fbeaee24ef4c74961 Mon Sep 17 00:00:00 2001
From: Jiayuan Zhang
Date: Tue, 24 Mar 2026 15:19:27 +0800
Subject: [PATCH 6/9] Provision default workspaces and harden daemon pairing
---
apps/web/app/favicon.ico/route.ts | 3 +
apps/web/app/layout.tsx | 4 +
apps/web/app/pair/local/page.test.tsx | 101 +++++++++++++++++++++
apps/web/app/pair/local/page.tsx | 9 +-
apps/web/public/favicon.svg | 7 ++
server/cmd/daemon/daemon.go | 80 ++++++++++++++++-
server/cmd/daemon/daemon_test.go | 19 ++++
server/cmd/server/integration_test.go | 82 +++++++++++++++++
server/internal/handler/auth.go | 111 ++++++++++++++++++++++++
server/internal/handler/daemon.go | 8 ++
server/internal/handler/handler_test.go | 73 ++++++++++++++++
11 files changed, 492 insertions(+), 5 deletions(-)
create mode 100644 apps/web/app/favicon.ico/route.ts
create mode 100644 apps/web/app/pair/local/page.test.tsx
create mode 100644 apps/web/public/favicon.svg
diff --git a/apps/web/app/favicon.ico/route.ts b/apps/web/app/favicon.ico/route.ts
new file mode 100644
index 00000000..00890acb
--- /dev/null
+++ b/apps/web/app/favicon.ico/route.ts
@@ -0,0 +1,3 @@
+export function GET(request: Request) {
+ return Response.redirect(new URL("/favicon.svg", request.url), 308);
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 5a544793..f83a84b2 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -8,6 +8,10 @@ import "./globals.css";
export const metadata: Metadata = {
title: "Multica",
description: "AI-native task management",
+ icons: {
+ icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
+ shortcut: ["/favicon.svg"],
+ },
};
export default function RootLayout({
diff --git a/apps/web/app/pair/local/page.test.tsx b/apps/web/app/pair/local/page.test.tsx
new file mode 100644
index 00000000..f4096865
--- /dev/null
+++ b/apps/web/app/pair/local/page.test.tsx
@@ -0,0 +1,101 @@
+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("../../../lib/api", () => ({
+ api: {
+ getDaemonPairingSession: mockGetDaemonPairingSession,
+ approveDaemonPairingSession: mockApproveDaemonPairingSession,
+ },
+}));
+
+vi.mock("../../../lib/auth-context", () => ({
+ useAuth: () => 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
index 8dc70471..3d2c3b66 100644
--- a/apps/web/app/pair/local/page.tsx
+++ b/apps/web/app/pair/local/page.tsx
@@ -9,7 +9,6 @@ import { Label } from "@multica/ui/components/ui/label";
import {
Select,
SelectTrigger,
- SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
@@ -39,6 +38,10 @@ function LocalDaemonPairPageContent() {
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) {
@@ -135,7 +138,9 @@ function LocalDaemonPairPageContent() {
Workspace