diff --git a/.env.example b/.env.example
index 14ed6161..1d54a44d 100644
--- a/.env.example
+++ b/.env.example
@@ -1,9 +1,15 @@
# Database
+COMPOSE_PROJECT_NAME=super_multica
+POSTGRES_DB=multica
+POSTGRES_USER=multica
+POSTGRES_PASSWORD=multica
+POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Server
PORT=8080
JWT_SECRET=change-me-in-production
+MULTICA_SERVER_URL=ws://localhost:8080/ws
# Google OAuth
GOOGLE_CLIENT_ID=
@@ -11,5 +17,7 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# Frontend
+FRONTEND_PORT=3000
+FRONTEND_ORIGIN=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
diff --git a/Makefile b/Makefile
index 3736cd27..8f36b591 100644
--- a/Makefile
+++ b/Makefile
@@ -1,18 +1,45 @@
-.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check
+.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
+
+MAIN_ENV_FILE ?= .env
+WORKTREE_ENV_FILE ?= .env.worktree
+ENV_FILE ?= $(if $(wildcard $(MAIN_ENV_FILE)),$(MAIN_ENV_FILE),$(if $(wildcard $(WORKTREE_ENV_FILE)),$(WORKTREE_ENV_FILE),$(MAIN_ENV_FILE)))
+
+ifneq ($(wildcard $(ENV_FILE)),)
+include $(ENV_FILE)
+endif
+
+POSTGRES_DB ?= multica
+POSTGRES_USER ?= multica
+POSTGRES_PASSWORD ?= multica
+POSTGRES_PORT ?= 5432
+PORT ?= 8080
+FRONTEND_PORT ?= 3000
+FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
+DATABASE_URL ?= postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@localhost:$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable
+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)
# ---------- One-click commands ----------
# First-time setup: install deps, start DB, run migrations, seed data
setup:
+ @echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
pnpm install
@echo "==> Starting PostgreSQL..."
- @if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \
+ @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 \
- docker compose up -d; \
+ $(COMPOSE) up -d; \
echo "==> Waiting for PostgreSQL to be ready..."; \
- until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \
+ until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \
sleep 1; \
done; \
fi
@@ -25,11 +52,14 @@ setup:
# Start all services (backend + frontend)
start:
- @if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \
+ @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 \
- docker compose up -d; \
- until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \
+ $(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
@@ -42,15 +72,42 @@ start:
# Stop all services
stop:
@echo "Stopping services..."
- @-lsof -ti:8080 | xargs kill -9 2>/dev/null
- @-lsof -ti:3000 | xargs kill -9 2>/dev/null
- docker compose down
+ @-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."
# Full verification: typecheck + unit tests + Go tests + E2E
check:
@bash scripts/check.sh
+worktree-env:
+ @bash scripts/init-worktree-env.sh .env.worktree
+
+setup-main:
+ @$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
+
+start-main:
+ @$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
+
+stop-main:
+ @$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
+
+check-main:
+ @ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
+
+setup-worktree:
+ @$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
+
+start-worktree:
+ @$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
+
+stop-worktree:
+ @$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
+
+check-worktree:
+ @ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
+
# ---------- Individual commands ----------
# Go server
diff --git a/README.md b/README.md
index 00fad72e..968cd110 100644
--- a/README.md
+++ b/README.md
@@ -15,23 +15,27 @@ AI-native task management platform — like Linear, but with AI agents as first-
# 1. Install dependencies
pnpm install
-# 2. Copy environment variables
+# 2. Copy environment variables for the shared main environment
cp .env.example .env
-# 3. Start PostgreSQL
-docker compose up -d
+# 3. One-time setup: start DB, run migrations, seed data
+make setup
-# 4. Run database migrations
-make migrate-up
-
-# 5. Start the Go backend (port 8080)
-make dev
-
-# 6. In another terminal, start the frontend (port 3000)
-pnpm dev:web
+# 4. Start backend + frontend
+make start
```
-Open [http://localhost:3000](http://localhost:3000) in your browser.
+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:
+
+```bash
+make worktree-env
+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.
## Project Structure
@@ -61,7 +65,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
| Command | Description |
|---------|-------------|
-| `pnpm dev:web` | Start Next.js dev server (port 3000) |
+| `pnpm dev:web` | Start Next.js dev server (uses `FRONTEND_PORT`, default `3000`) |
| `pnpm build` | Build all TypeScript packages |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run TypeScript tests |
@@ -70,7 +74,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
| Command | Description |
|---------|-------------|
-| `make dev` | Run Go server (port 8080) |
+| `make dev` | Run Go server (uses `PORT`, default `8080`) |
| `make daemon` | Run local agent daemon |
| `make test` | Run Go tests |
| `make build` | Build server & daemon binaries |
@@ -85,13 +89,19 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
| `make migrate-up` | Run database migrations |
| `make migrate-down` | Rollback database migrations |
| `make seed` | Seed test data |
+| `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` |
## Environment Variables
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
- `PORT` — Backend server port (default: 8080)
+- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
- `JWT_SECRET` — JWT signing secret
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` — Google OAuth (optional)
- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL
diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx
index e902b5e3..29cafbd7 100644
--- a/apps/web/app/(dashboard)/layout.tsx
+++ b/apps/web/app/(dashboard)/layout.tsx
@@ -38,6 +38,12 @@ export default function DashboardLayout({
const [newSlug, setNewSlug] = useState("");
const [creating, setCreating] = useState(false);
+ useEffect(() => {
+ if (!isLoading && user && workspaces.length === 0) {
+ setShowCreateDialog(true);
+ }
+ }, [isLoading, user, workspaces.length]);
+
const handleNameChange = (value: string) => {
setNewName(value);
setNewSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""));
@@ -75,6 +81,100 @@ export default function DashboardLayout({
if (!user) return null;
+ if (!workspace) {
+ return (
+ <>
+
+
+
+
+
+
+
+
Create your first workspace
+
{user.email}
+
+
+
+
+ You need a workspace before you can manage issues, agents, and inbox items.
+
+
+
+
+
+
+
+
+
+ {showCreateDialog && (
+ <>
+ setShowCreateDialog(false)}
+ />
+
+
+
Create workspace
+
+ Create a new workspace for your team.
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ );
+ }
+
return (
{/* Sidebar */}
diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx
index 2e1f8992..43621ccb 100644
--- a/apps/web/app/(dashboard)/settings/page.tsx
+++ b/apps/web/app/(dashboard)/settings/page.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useState } from "react";
-import { Settings, Users, Building2, Save, Crown, Shield, User } from "lucide-react";
+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 { useAuth } from "../../../lib/auth-context";
import { api } from "../../../lib/api";
@@ -12,9 +12,27 @@ const roleConfig: Record
= {
member: { label: "Member", icon: User },
};
-function MemberRow({ member }: { member: MemberWithUser }) {
+function MemberRow({
+ member,
+ canManage,
+ canManageOwners,
+ isSelf,
+ busy,
+ onRoleChange,
+ onRemove,
+}: {
+ member: MemberWithUser;
+ canManage: boolean;
+ canManageOwners: boolean;
+ isSelf: boolean;
+ busy: boolean;
+ onRoleChange: (role: MemberRole) => void;
+ onRemove: () => void;
+}) {
const rc = roleConfig[member.role];
const RoleIcon = rc.icon;
+ const canEditRole = canManage && (!isSelf || canManageOwners) && (member.role !== "owner" || canManageOwners);
+ const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
return (
@@ -30,27 +48,84 @@ function MemberRow({ member }: { member: MemberWithUser }) {
{member.name}
{member.email}
-
-
- {rc.label}
-
+ {canEditRole ? (
+
+ ) : (
+
+
+ {rc.label}
+
+ )}
+ {canRemove && (
+
+ )}
);
}
export default function SettingsPage() {
- const { workspace, members, updateWorkspace } = useAuth();
+ const {
+ user,
+ workspace,
+ members,
+ updateWorkspace,
+ updateCurrentUser,
+ refreshMembers,
+ leaveWorkspace,
+ deleteWorkspace,
+ } = useAuth();
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(
workspace?.description ?? "",
);
+ const [profileName, setProfileName] = useState(user?.name ?? "");
+ const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
+ const [profileSaving, setProfileSaving] = useState(false);
+ const [profileSaved, setProfileSaved] = useState(false);
+ const [inviteEmail, setInviteEmail] = useState("");
+ const [inviteRole, setInviteRole] = useState
("member");
+ const [inviteLoading, setInviteLoading] = useState(false);
+ const [memberActionId, setMemberActionId] = useState(null);
+ const [workspaceError, setWorkspaceError] = useState("");
+ const [profileError, setProfileError] = useState("");
+ const [memberError, setMemberError] = useState("");
+ const currentMember = members.find((member) => member.user_id === user?.id) ?? null;
+ const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
+ const isOwner = currentMember?.role === "owner";
+
+ useEffect(() => {
+ setName(workspace?.name ?? "");
+ setDescription(workspace?.description ?? "");
+ }, [workspace]);
+
+ useEffect(() => {
+ setProfileName(user?.name ?? "");
+ setAvatarUrl(user?.avatar_url ?? "");
+ }, [user]);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
+ setWorkspaceError("");
try {
const updated = await api.updateWorkspace(workspace.id, {
name,
@@ -60,12 +135,109 @@ export default function SettingsPage() {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
- console.error("Failed to update workspace", e);
+ setWorkspaceError(e instanceof Error ? e.message : "Failed to update workspace");
} finally {
setSaving(false);
}
};
+ const handleProfileSave = async () => {
+ setProfileSaving(true);
+ setProfileError("");
+ try {
+ const updated = await api.updateMe({
+ name: profileName,
+ avatar_url: avatarUrl || undefined,
+ });
+ updateCurrentUser(updated);
+ setProfileSaved(true);
+ setTimeout(() => setProfileSaved(false), 2000);
+ } catch (e) {
+ setProfileError(e instanceof Error ? e.message : "Failed to update profile");
+ } finally {
+ setProfileSaving(false);
+ }
+ };
+
+ const handleAddMember = async () => {
+ if (!workspace) return;
+ setInviteLoading(true);
+ setMemberError("");
+ try {
+ await api.createMember(workspace.id, {
+ email: inviteEmail,
+ role: inviteRole,
+ });
+ setInviteEmail("");
+ setInviteRole("member");
+ await refreshMembers();
+ } catch (e) {
+ setMemberError(e instanceof Error ? e.message : "Failed to add member");
+ } finally {
+ setInviteLoading(false);
+ }
+ };
+
+ const handleRoleChange = async (memberId: string, role: MemberRole) => {
+ if (!workspace) return;
+ setMemberActionId(memberId);
+ setMemberError("");
+ try {
+ await api.updateMember(workspace.id, memberId, { role });
+ await refreshMembers();
+ } catch (e) {
+ setMemberError(e instanceof Error ? e.message : "Failed to update member");
+ } finally {
+ setMemberActionId(null);
+ }
+ };
+
+ const handleRemoveMember = async (member: MemberWithUser) => {
+ if (!workspace) return;
+ if (!window.confirm(`Remove ${member.name} from ${workspace.name}?`)) return;
+
+ setMemberActionId(member.id);
+ setMemberError("");
+ try {
+ await api.deleteMember(workspace.id, member.id);
+ await refreshMembers();
+ } catch (e) {
+ setMemberError(e instanceof Error ? e.message : "Failed to remove member");
+ } finally {
+ setMemberActionId(null);
+ }
+ };
+
+ const handleLeaveWorkspace = async () => {
+ if (!workspace) return;
+ if (!window.confirm(`Leave ${workspace.name}?`)) return;
+
+ setMemberActionId("leave");
+ setMemberError("");
+ try {
+ await leaveWorkspace(workspace.id);
+ } catch (e) {
+ setMemberError(e instanceof Error ? e.message : "Failed to leave workspace");
+ } finally {
+ setMemberActionId(null);
+ }
+ };
+
+ const handleDeleteWorkspace = async () => {
+ if (!workspace) return;
+ if (!window.confirm(`Delete ${workspace.name}? This cannot be undone.`)) return;
+
+ setMemberActionId("delete-workspace");
+ setMemberError("");
+ try {
+ await deleteWorkspace(workspace.id);
+ } catch (e) {
+ setMemberError(e instanceof Error ? e.message : "Failed to delete workspace");
+ } finally {
+ setMemberActionId(null);
+ }
+ };
+
if (!workspace) return null;
return (
@@ -76,6 +248,55 @@ export default function SettingsPage() {
Settings
+
+
+
+
Profile
+
+
+
+
+
+ setProfileName(e.target.value)}
+ className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+
+
+
+ setAvatarUrl(e.target.value)}
+ placeholder="https://example.com/avatar.png"
+ className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+
+ {profileError && (
+
{profileError}
+ )}
+
+ {profileSaved && (
+ Saved!
+ )}
+
+
+
+
+
{/* Workspace info */}
@@ -92,6 +313,7 @@ export default function SettingsPage() {
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
+ disabled={!canManageWorkspace}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
@@ -103,6 +325,7 @@ export default function SettingsPage() {
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
+ disabled={!canManageWorkspace}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
placeholder="What does this workspace focus on?"
/>
@@ -116,18 +339,26 @@ export default function SettingsPage() {
+ {workspaceError && (
+ {workspaceError}
+ )}
{saved && (
Saved!
)}
+ {!canManageWorkspace && (
+
+ Only admins and owners can update workspace settings.
+
+ )}
@@ -142,15 +373,105 @@ export default function SettingsPage() {
+ {memberError && (
+ {memberError}
+ )}
+
+ {canManageWorkspace && (
+
+
+
+ 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"
+ />
+
+
+
+
+ )}
+
{members.map((m) => (
-
+
handleRoleChange(m.id, role)}
+ onRemove={() => handleRemoveMember(m)}
+ />
))}
{members.length === 0 && (
No members found.
)}
+
+
+
+
+
Danger Zone
+
+
+
+
+
+
Leave workspace
+
+ Remove yourself from this workspace.
+
+
+
+
+
+ {isOwner && (
+
+
+
Delete workspace
+
+ Permanently delete this workspace and its data.
+
+
+
+
+ )}
+
+
);
}
diff --git a/apps/web/lib/auth-context.test.tsx b/apps/web/lib/auth-context.test.tsx
index cc918a35..4431c0ff 100644
--- a/apps/web/lib/auth-context.test.tsx
+++ b/apps/web/lib/auth-context.test.tsx
@@ -4,8 +4,9 @@ import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
// Mock next/navigation
const mockPush = vi.fn();
+const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
- useRouter: () => ({ push: mockPush }),
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
}));
// Must use vi.hoisted so the mock object is defined before vi.mock factory runs
@@ -18,6 +19,9 @@ const mockApi = vi.hoisted(() => ({
listMembers: vi.fn(),
listAgents: vi.fn(),
createWorkspace: vi.fn(),
+ updateMe: vi.fn(),
+ leaveWorkspace: vi.fn(),
+ deleteWorkspace: vi.fn(),
}));
vi.mock("./api", () => ({
@@ -393,12 +397,6 @@ describe("AuthContext", () => {
});
it("switchWorkspace updates context and calls setWorkspaceId", async () => {
- const reloadMock = vi.fn();
- Object.defineProperty(window, "location", {
- value: { ...window.location, reload: reloadMock },
- writable: true,
- });
-
const mockWorkspace2: Workspace = {
id: "ws-2",
name: "Second WS",
@@ -434,6 +432,6 @@ describe("AuthContext", () => {
expect(mockApi.setWorkspaceId).toHaveBeenCalledWith("ws-2");
expect(localStorage.getItem("multica_workspace_id")).toBe("ws-2");
- expect(reloadMock).toHaveBeenCalled();
+ expect(mockRefresh).toHaveBeenCalled();
});
});
diff --git a/apps/web/lib/auth-context.tsx b/apps/web/lib/auth-context.tsx
index 18333b6e..5b64a016 100644
--- a/apps/web/lib/auth-context.tsx
+++ b/apps/web/lib/auth-context.tsx
@@ -24,6 +24,10 @@ interface AuthContextValue {
switchWorkspace: (workspaceId: string) => Promise;
createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise;
updateWorkspace: (ws: Workspace) => void;
+ updateCurrentUser: (nextUser: User) => void;
+ leaveWorkspace: (workspaceId: string) => Promise;
+ deleteWorkspace: (workspaceId: string) => Promise;
+ refreshWorkspaces: () => Promise;
refreshMembers: () => Promise;
refreshAgents: () => Promise;
getMemberName: (userId: string) => string;
@@ -43,6 +47,44 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
+ const hydrateWorkspace = useCallback(async (wsList: Workspace[], preferredWorkspaceId?: string | null) => {
+ setWorkspaces(wsList);
+
+ const nextWorkspace =
+ (preferredWorkspaceId ? wsList.find((item) => item.id === preferredWorkspaceId) : null) ??
+ wsList[0] ??
+ null;
+
+ if (!nextWorkspace) {
+ api.setWorkspaceId(null);
+ localStorage.removeItem("multica_workspace_id");
+ setWorkspace(null);
+ setMembers([]);
+ setAgents([]);
+ return null;
+ }
+
+ api.setWorkspaceId(nextWorkspace.id);
+ localStorage.setItem("multica_workspace_id", nextWorkspace.id);
+ setWorkspace(nextWorkspace);
+
+ const [nextMembers, nextAgents] = await Promise.all([
+ api.listMembers(nextWorkspace.id),
+ api.listAgents({ workspace_id: nextWorkspace.id }),
+ ]);
+ setMembers(nextMembers);
+ setAgents(nextAgents);
+
+ return nextWorkspace;
+ }, []);
+
+ const refreshWorkspaces = useCallback(async () => {
+ const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
+ const wsList = await api.listWorkspaces();
+ await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
+ return wsList;
+ }, [hydrateWorkspace, workspace]);
+
// Initialize from stored token
useEffect(() => {
const token = localStorage.getItem("multica_token");
@@ -53,7 +95,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
api.setToken(token);
- if (wsId) api.setWorkspaceId(wsId);
+ api.setWorkspaceId(wsId);
(async () => {
try {
@@ -61,30 +103,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(me);
const wsList = await api.listWorkspaces();
- setWorkspaces(wsList);
- if (wsList.length > 0) {
- const stored = wsId ? wsList.find(w => w.id === wsId) : null;
- const ws = stored ?? wsList[0]!;
- setWorkspace(ws);
- api.setWorkspaceId(ws.id);
- localStorage.setItem("multica_workspace_id", ws.id);
-
- const [m, a] = await Promise.all([
- api.listMembers(ws.id),
- api.listAgents({ workspace_id: ws.id }),
- ]);
- setMembers(m);
- setAgents(a);
- }
+ await hydrateWorkspace(wsList, wsId);
} catch {
// Token invalid, clear it
+ api.setToken(null);
+ api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
+ setUser(null);
+ setWorkspace(null);
+ setWorkspaces([]);
+ setMembers([]);
+ setAgents([]);
} finally {
setIsLoading(false);
}
})();
- }, []);
+ }, [hydrateWorkspace]);
const login = useCallback(async (email: string, name?: string) => {
const { token, user: u } = await api.login(email, name);
@@ -92,27 +127,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem("multica_token", token);
setUser(u);
- // Load workspace
const wsList = await api.listWorkspaces();
- setWorkspaces(wsList);
- if (wsList.length > 0) {
- const ws = wsList[0]!;
- setWorkspace(ws);
- api.setWorkspaceId(ws.id);
- localStorage.setItem("multica_workspace_id", ws.id);
-
- const [m, a] = await Promise.all([
- api.listMembers(ws.id),
- api.listAgents({ workspace_id: ws.id }),
- ]);
- setMembers(m);
- setAgents(a);
- }
+ await hydrateWorkspace(wsList);
router.push("/issues");
- }, [router]);
+ }, [hydrateWorkspace, router]);
const logout = useCallback(() => {
+ api.setToken(null);
+ api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
setUser(null);
@@ -124,33 +147,45 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [router]);
const switchWorkspace = useCallback(async (workspaceId: string) => {
- const ws = workspaces.find(w => w.id === workspaceId);
+ const ws = workspaces.find((item) => item.id === workspaceId);
if (!ws) return;
- api.setWorkspaceId(ws.id);
- localStorage.setItem("multica_workspace_id", ws.id);
- setWorkspace(ws);
-
- const [m, a] = await Promise.all([
- api.listMembers(ws.id),
- api.listAgents({ workspace_id: ws.id }),
- ]);
- setMembers(m);
- setAgents(a);
-
- window.location.reload();
- }, [workspaces]);
+ await hydrateWorkspace(workspaces, ws.id);
+ router.refresh();
+ }, [hydrateWorkspace, router, workspaces]);
const createNewWorkspace = useCallback(async (data: { name: string; slug: string; description?: string }) => {
const ws = await api.createWorkspace(data);
- setWorkspaces(prev => [...prev, ws]);
+ setWorkspaces((prev) => [...prev, ws]);
return ws;
}, []);
const updateWorkspaceState = useCallback((ws: Workspace) => {
setWorkspace(ws);
+ setWorkspaces((prev) => prev.map((item) => (item.id === ws.id ? ws : item)));
}, []);
+ const updateCurrentUser = useCallback((nextUser: User) => {
+ setUser(nextUser);
+ }, []);
+
+ const reloadAfterWorkspaceRemoval = useCallback(async (removedWorkspaceId: string) => {
+ const wsList = await api.listWorkspaces();
+ const preferredWorkspaceId = workspace?.id === removedWorkspaceId ? null : workspace?.id ?? null;
+ await hydrateWorkspace(wsList, preferredWorkspaceId);
+ router.refresh();
+ }, [hydrateWorkspace, router, workspace]);
+
+ const leaveWorkspace = useCallback(async (workspaceId: string) => {
+ await api.leaveWorkspace(workspaceId);
+ await reloadAfterWorkspaceRemoval(workspaceId);
+ }, [reloadAfterWorkspaceRemoval]);
+
+ const deleteWorkspace = useCallback(async (workspaceId: string) => {
+ await api.deleteWorkspace(workspaceId);
+ await reloadAfterWorkspaceRemoval(workspaceId);
+ }, [reloadAfterWorkspaceRemoval]);
+
const refreshMembers = useCallback(async () => {
if (!workspace) return;
const m = await api.listMembers(workspace.id);
@@ -215,6 +250,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
switchWorkspace,
createWorkspace: createNewWorkspace,
updateWorkspace: updateWorkspaceState,
+ updateCurrentUser,
+ leaveWorkspace,
+ deleteWorkspace,
+ refreshWorkspaces,
refreshMembers,
refreshAgents,
getMemberName,
diff --git a/apps/web/package.json b/apps/web/package.json
index 3d5aa923..e64ff3bf 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "next dev --port 3000",
+ "dev": "sh -c 'next dev --port \"${FRONTEND_PORT:-3000}\"'",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx
index 717a1c51..ab472138 100644
--- a/apps/web/test/helpers.tsx
+++ b/apps/web/test/helpers.tsx
@@ -66,6 +66,14 @@ export const mockAuthValue: Record = {
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
+ workspaces: [mockWorkspace],
+ switchWorkspace: vi.fn(),
+ createWorkspace: vi.fn(),
+ updateWorkspace: vi.fn(),
+ updateCurrentUser: vi.fn(),
+ leaveWorkspace: vi.fn(),
+ deleteWorkspace: vi.fn(),
+ refreshWorkspaces: vi.fn(),
refreshMembers: vi.fn(),
refreshAgents: vi.fn(),
getMemberName: (userId: string) => {
diff --git a/docker-compose.yml b/docker-compose.yml
index 8d424b6c..21b005f3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,11 +2,11 @@ services:
postgres:
image: pgvector/pgvector:pg17
environment:
- POSTGRES_DB: multica
- POSTGRES_USER: multica
- POSTGRES_PASSWORD: multica
+ POSTGRES_DB: ${POSTGRES_DB:-multica}
+ POSTGRES_USER: ${POSTGRES_USER:-multica}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- - "5432:5432"
+ - "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts
index 02ba1beb..4c3b2a09 100644
--- a/e2e/fixtures.ts
+++ b/e2e/fixtures.ts
@@ -5,7 +5,7 @@
* have zero build-time coupling to monorepo packages.
*/
-const API_BASE = "http://localhost:8080";
+const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
export class TestApiClient {
private token: string | null = null;
diff --git a/packages/sdk/src/api-client.ts b/packages/sdk/src/api-client.ts
index 2128c052..811b4946 100644
--- a/packages/sdk/src/api-client.ts
+++ b/packages/sdk/src/api-client.ts
@@ -3,6 +3,9 @@ import type {
CreateIssueRequest,
UpdateIssueRequest,
ListIssuesResponse,
+ UpdateMeRequest,
+ CreateMemberRequest,
+ UpdateMemberRequest,
Agent,
InboxItem,
Comment,
@@ -25,11 +28,11 @@ export class ApiClient {
this.baseUrl = baseUrl;
}
- setToken(token: string) {
+ setToken(token: string | null) {
this.token = token;
}
- setWorkspaceId(id: string) {
+ setWorkspaceId(id: string | null) {
this.workspaceId = id;
}
@@ -51,7 +54,16 @@ export class ApiClient {
});
if (!res.ok) {
- throw new Error(`API error: ${res.status} ${res.statusText}`);
+ let message = `API error: ${res.status} ${res.statusText}`;
+ try {
+ const data = await res.json() as { error?: string };
+ if (typeof data.error === "string" && data.error) {
+ message = data.error;
+ }
+ } catch {
+ // Ignore non-JSON error bodies.
+ }
+ throw new Error(message);
}
// Handle 204 No Content
@@ -74,6 +86,13 @@ export class ApiClient {
return this.fetch("/api/me");
}
+ async updateMe(data: UpdateMeRequest): Promise {
+ return this.fetch("/api/me", {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ });
+ }
+
// Issues
async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise {
const search = new URLSearchParams();
@@ -163,7 +182,7 @@ export class ApiClient {
async updateWorkspace(id: string, data: { name?: string; description?: string; settings?: Record }): Promise {
return this.fetch(`/api/workspaces/${id}`, {
- method: "PUT",
+ method: "PATCH",
body: JSON.stringify(data),
});
}
@@ -172,4 +191,36 @@ export class ApiClient {
async listMembers(workspaceId: string): Promise {
return this.fetch(`/api/workspaces/${workspaceId}/members`);
}
+
+ async createMember(workspaceId: string, data: CreateMemberRequest): Promise {
+ return this.fetch(`/api/workspaces/${workspaceId}/members`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ }
+
+ async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise {
+ return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ });
+ }
+
+ async deleteMember(workspaceId: string, memberId: string): Promise {
+ await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
+ method: "DELETE",
+ });
+ }
+
+ async leaveWorkspace(workspaceId: string): Promise {
+ await this.fetch(`/api/workspaces/${workspaceId}/leave`, {
+ method: "POST",
+ });
+ }
+
+ async deleteWorkspace(workspaceId: string): Promise {
+ await this.fetch(`/api/workspaces/${workspaceId}`, {
+ method: "DELETE",
+ });
+ }
}
diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts
index 421b5e6e..b9d42769 100644
--- a/packages/types/src/api.ts
+++ b/packages/types/src/api.ts
@@ -1,4 +1,5 @@
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
+import type { MemberRole } from "./workspace.js";
// Issue API
export interface CreateIssueRequest {
@@ -29,6 +30,20 @@ export interface ListIssuesResponse {
total: number;
}
+export interface UpdateMeRequest {
+ name?: string;
+ avatar_url?: string;
+}
+
+export interface CreateMemberRequest {
+ email: string;
+ role?: MemberRole;
+}
+
+export interface UpdateMemberRequest {
+ role: MemberRole;
+}
+
// Pagination
export interface PaginationParams {
limit?: number;
diff --git a/playwright.config.ts b/playwright.config.ts
index e3c16b5e..795e0825 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
timeout: 30000,
retries: 0,
use: {
- baseURL: "http://localhost:3000",
+ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? process.env.FRONTEND_ORIGIN ?? "http://localhost:3000",
headless: true,
},
projects: [
diff --git a/scripts/check.sh b/scripts/check.sh
index 1cddbea1..ef4af067 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -6,6 +6,24 @@ 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
+fi
+
+POSTGRES_DB="${POSTGRES_DB:-multica}"
+POSTGRES_USER="${POSTGRES_USER:-multica}"
+POSTGRES_PORT="${POSTGRES_PORT:-5432}"
+PORT="${PORT:-8080}"
+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
@@ -57,13 +75,14 @@ wait_for_port() {
# --------------------------------------------------------------------------
# Step 0: Ensure DB
# --------------------------------------------------------------------------
+echo "==> Using env file: $ENV_FILE"
echo "==> Checking PostgreSQL..."
-if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then
+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..."
- docker compose up -d
- until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do
+ "${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."
@@ -96,24 +115,24 @@ echo "==> [3/5] Go tests..."
echo ""
echo "==> [4/5] Starting services for E2E..."
-if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
- echo " Backend already running on :8080"
+if curl -sf "http://localhost:${PORT}/health" > /dev/null 2>&1; then
+ echo " Backend already running on :$PORT"
else
echo " Starting backend..."
(cd server && go run ./cmd/server) > /tmp/multica-check-backend.log 2>&1 &
BACKEND_PID=$!
STARTED_BACKEND=true
- wait_for_port 8080 "Backend" 90 "/health"
+ wait_for_port "$PORT" "Backend" 90 "/health"
fi
-if curl -sf http://localhost:3000 > /dev/null 2>&1; then
- echo " Frontend already running on :3000"
+if curl -sf "http://localhost:${FRONTEND_PORT}" > /dev/null 2>&1; then
+ echo " Frontend already running on :$FRONTEND_PORT"
else
echo " Starting frontend..."
pnpm dev:web > /tmp/multica-check-frontend.log 2>&1 &
FRONTEND_PID=$!
STARTED_FRONTEND=true
- wait_for_port 3000 "Frontend" 120 "/"
+ wait_for_port "$FRONTEND_PORT" "Frontend" 120 "/"
fi
# --------------------------------------------------------------------------
diff --git a/scripts/init-worktree-env.sh b/scripts/init-worktree-env.sh
new file mode 100644
index 00000000..cf123bef
--- /dev/null
+++ b/scripts/init-worktree-env.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ENV_FILE="${1:-.env.worktree}"
+
+if [ -f "$ENV_FILE" ] && [ "${FORCE:-0}" != "1" ]; then
+ echo "Refusing to overwrite existing $ENV_FILE. Re-run with FORCE=1 if you want to regenerate it."
+ exit 1
+fi
+
+worktree_name="${WORKTREE_NAME:-$(basename "$PWD")}"
+slug="$(printf '%s' "$worktree_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
+if [ -z "$slug" ]; then
+ slug="multica"
+fi
+
+hash_value="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
+offset=$((hash_value % 1000))
+
+postgres_db="multica_${slug}"
+postgres_port=$((15432 + offset))
+backend_port=$((18080 + offset))
+frontend_port=$((13000 + offset))
+frontend_origin="http://localhost:${frontend_port}"
+compose_project_name="multica_${slug}_${offset}"
+
+cat > "$ENV_FILE" <