From 7b4a73c989dcf34c73f757dcd5902c22a2e02cf1 Mon Sep 17 00:00:00 2001 From: yushen Date: Thu, 26 Mar 2026 16:04:33 +0800 Subject: [PATCH 01/20] refactor(daemon): remove global ReposRoot, use per-task RepoPath from server ReposRoot was a daemon-level config that locked all tasks to a single git repo. Replace with RepoPath in TaskContext so the server can specify the repo per task. When not provided, daemon falls back to directory mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 2 +- server/cmd/multica/cmd_daemon.go | 2 -- server/internal/daemon/config.go | 28 +++---------------- server/internal/daemon/daemon.go | 4 +-- server/internal/daemon/execenv/execenv.go | 6 ++-- .../internal/daemon/execenv/execenv_test.go | 8 +++--- server/internal/daemon/types.go | 1 + 7 files changed, 15 insertions(+), 36 deletions(-) diff --git a/Makefile b/Makefile index 811c8637..e7952c87 100644 --- a/Makefile +++ b/Makefile @@ -115,7 +115,7 @@ dev: cd server && go run ./cmd/server daemon: - cd server && MULTICA_REPOS_ROOT="${MULTICA_REPOS_ROOT:-$(abspath .)}" go run ./cmd/multica daemon + cd server && go run ./cmd/multica daemon cli: cd server && go run ./cmd/multica $(ARGS) diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index 21c994e9..d1ffc8ff 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -22,7 +22,6 @@ var daemonCmd = &cobra.Command{ func init() { f := daemonCmd.Flags() - f.String("repos-root", "", "Base directory for task repositories (env: MULTICA_REPOS_ROOT)") f.String("config-path", "", "Path to daemon config file (env: MULTICA_DAEMON_CONFIG)") f.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)") f.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)") @@ -36,7 +35,6 @@ func runDaemon(cmd *cobra.Command, _ []string) error { overrides := daemon.Overrides{ ServerURL: cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", ""), WorkspaceID: cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", ""), - ReposRoot: flagString(cmd, "repos-root"), ConfigPath: flagString(cmd, "config-path"), DaemonID: flagString(cmd, "daemon-id"), DeviceName: flagString(cmd, "device-name"), diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 628a31d6..83bcdc49 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -30,7 +30,6 @@ type Config struct { DeviceName string RuntimeName string Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry - ReposRoot string // parent directory containing all repos WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces) KeepEnvAfterTask bool // preserve env after task for debugging PollInterval time.Duration @@ -43,7 +42,6 @@ type Config struct { type Overrides struct { ServerURL string WorkspaceID string - ReposRoot string WorkspacesRoot string ConfigPath string PollInterval time.Duration @@ -118,22 +116,6 @@ func LoadConfig(overrides Overrides) (Config, error) { host = "local-machine" } - // Repos root: override > env > cwd - reposRoot := strings.TrimSpace(os.Getenv("MULTICA_REPOS_ROOT")) - if overrides.ReposRoot != "" { - reposRoot = overrides.ReposRoot - } - if reposRoot == "" { - reposRoot, err = os.Getwd() - if err != nil { - return Config{}, fmt.Errorf("resolve working directory: %w", err) - } - } - reposRoot, err = filepath.Abs(reposRoot) - if err != nil { - return Config{}, fmt.Errorf("resolve absolute repos root: %w", err) - } - // Durations: override > env > default pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", DefaultPollInterval) if err != nil { @@ -181,12 +163,11 @@ func LoadConfig(overrides Overrides) (Config, error) { workspacesRoot = overrides.WorkspacesRoot } if workspacesRoot == "" { - home, _ := os.UserHomeDir() - if home != "" { - workspacesRoot = filepath.Join(home, "multica_workspaces") - } else { - workspacesRoot = filepath.Join(reposRoot, "multica_workspaces") + home, err := os.UserHomeDir() + if err != nil { + return Config{}, fmt.Errorf("resolve home directory: %w (set MULTICA_WORKSPACES_ROOT to override)", err) } + workspacesRoot = filepath.Join(home, "multica_workspaces") } workspacesRoot, err = filepath.Abs(workspacesRoot) if err != nil { @@ -204,7 +185,6 @@ func LoadConfig(overrides Overrides) (Config, error) { DeviceName: deviceName, RuntimeName: runtimeName, Agents: agents, - ReposRoot: reposRoot, WorkspacesRoot: workspacesRoot, KeepEnvAfterTask: keepEnv, PollInterval: pollInterval, diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 5b746d65..6fd2c8b7 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -33,7 +33,7 @@ func (d *Daemon) Run(ctx context.Context) error { for name := range d.cfg.Agents { agentNames = append(agentNames, name) } - d.logger.Info("starting daemon", "agents", agentNames, "workspace_id", d.cfg.WorkspaceID, "server", d.cfg.ServerBaseURL, "repos_root", d.cfg.ReposRoot) + d.logger.Info("starting daemon", "agents", agentNames, "workspace_id", d.cfg.WorkspaceID, "server", d.cfg.ServerBaseURL) if strings.TrimSpace(d.cfg.WorkspaceID) == "" { workspaceID, err := d.ensurePaired(ctx) @@ -279,7 +279,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) { } env, err := execenv.Prepare(execenv.PrepareParams{ WorkspacesRoot: d.cfg.WorkspacesRoot, - ReposRoot: d.cfg.ReposRoot, + RepoPath: task.Context.RepoPath, TaskID: task.ID, AgentName: task.Context.Agent.Name, Task: taskCtx, diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index a5f427c8..3d56fa46 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -21,7 +21,7 @@ const ( // PrepareParams holds all inputs needed to set up an execution environment. type PrepareParams struct { WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces) - ReposRoot string // source git repo (for worktree creation) + RepoPath string // source git repo path (for worktree creation), provided per-task by server TaskID string // task UUID — used for directory name AgentName string // for git branch naming only Task TaskContextForEnv // context data for writing files @@ -100,8 +100,8 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { } // Detect git repo and set up worktree if available. - if params.ReposRoot != "" { - if gitRoot, ok := detectGitRepo(params.ReposRoot); ok { + if params.RepoPath != "" { + if gitRoot, ok := detectGitRepo(params.RepoPath); ok { branchName := fmt.Sprintf("agent/%s/%s", sanitizeName(params.AgentName), shortID(params.TaskID)) // Get the default branch as base ref. diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index 4ab8bc49..1526c53d 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -96,7 +96,7 @@ func TestPrepareDirectoryMode(t *testing.T) { env, err := Prepare(PrepareParams{ WorkspacesRoot: workspacesRoot, - ReposRoot: reposRoot, + RepoPath: reposRoot, TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", AgentName: "Test Agent", Task: TaskContextForEnv{ @@ -176,7 +176,7 @@ func TestPrepareGitWorktreeMode(t *testing.T) { env, err := Prepare(PrepareParams{ WorkspacesRoot: workspacesRoot, - ReposRoot: reposRoot, + RepoPath: reposRoot, TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", AgentName: "Code Reviewer", Task: TaskContextForEnv{ @@ -334,7 +334,7 @@ func TestCleanupGitWorktree(t *testing.T) { env, err := Prepare(PrepareParams{ WorkspacesRoot: workspacesRoot, - ReposRoot: reposRoot, + RepoPath: reposRoot, TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012", AgentName: "Cleanup Test", Task: TaskContextForEnv{IssueTitle: "Cleanup test"}, @@ -477,7 +477,7 @@ func TestCleanupPreservesLogs(t *testing.T) { env, err := Prepare(PrepareParams{ WorkspacesRoot: workspacesRoot, - ReposRoot: t.TempDir(), // not a git repo + RepoPath: t.TempDir(), // not a git repo TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123", AgentName: "Preserve Test", Task: TaskContextForEnv{IssueTitle: "Preserve test"}, diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index a9774506..47bdb9f1 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -49,6 +49,7 @@ type TaskContext struct { Agent AgentContext `json:"agent"` Runtime RuntimeContext `json:"runtime"` WorkspaceContext string `json:"workspace_context,omitempty"` + RepoPath string `json:"repo_path,omitempty"` } // IssueContext holds issue details for task execution. From 669b18e1c9917f1b8e05b429ea3547ea9893a85c Mon Sep 17 00:00:00 2001 From: yushen Date: Thu, 26 Mar 2026 16:33:49 +0800 Subject: [PATCH 02/20] feat(auth): skip email verification for CLI login when already authenticated When the browser has an existing valid session and the login page is opened with cli_callback, show a one-click "Authorize CLI" confirmation instead of requiring email verification again. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/(auth)/login/page.tsx | 121 ++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 19 deletions(-) diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index de94a4ee..dc8d0d37 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -21,6 +21,28 @@ import { InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; +import type { User } from "@multica/types"; + +function validateCliCallback(cliCallback: string): boolean { + try { + const cbUrl = new URL(cliCallback); + if (cbUrl.protocol !== "http:") return false; + if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1") + return false; + return true; + } catch { + return false; + } +} + +function redirectToCliCallback( + cliCallback: string, + token: string, + cliState: string +) { + const separator = cliCallback.includes("?") ? "&" : "?"; + window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`; +} function LoginPageContent() { const router = useRouter(); @@ -29,12 +51,38 @@ function LoginPageContent() { const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace); const searchParams = useSearchParams(); - const [step, setStep] = useState<"email" | "code">("email"); + const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email"); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [cooldown, setCooldown] = useState(0); + const [existingUser, setExistingUser] = useState(null); + + // Check for existing session when CLI callback is present. + useEffect(() => { + const cliCallback = searchParams.get("cli_callback"); + if (!cliCallback) return; + + const token = localStorage.getItem("multica_token"); + if (!token) return; + + if (!validateCliCallback(cliCallback)) return; + + // Verify the existing token is still valid. + api.setToken(token); + api + .getMe() + .then((user) => { + setExistingUser(user); + setStep("cli_confirm"); + }) + .catch(() => { + // Token expired/invalid — clear and fall through to normal login. + api.setToken(null); + localStorage.removeItem("multica_token"); + }); + }, [searchParams]); useEffect(() => { if (cooldown <= 0) return; @@ -42,6 +90,14 @@ function LoginPageContent() { return () => clearTimeout(timer); }, [cooldown]); + const handleCliAuthorize = async () => { + const cliCallback = searchParams.get("cli_callback")!; + const cliState = searchParams.get("cli_state") || ""; + const token = localStorage.getItem("multica_token")!; + setSubmitting(true); + redirectToCliCallback(cliCallback, token, cliState); + }; + const handleSendCode = async (e?: React.FormEvent) => { e?.preventDefault(); if (!email) { @@ -57,7 +113,9 @@ function LoginPageContent() { setCooldown(10); } catch (err) { setError( - err instanceof Error ? err.message : "Failed to send code. Make sure the server is running." + err instanceof Error + ? err.message + : "Failed to send code. Make sure the server is running." ); } finally { setSubmitting(false); @@ -72,29 +130,14 @@ function LoginPageContent() { try { const cliCallback = searchParams.get("cli_callback"); if (cliCallback) { - // CLI browser login: verify code, get JWT, redirect to CLI callback. - // Only allow http://localhost callbacks to prevent open redirect / JWT theft. - try { - const cbUrl = new URL(cliCallback); - if (cbUrl.protocol !== "http:") { - setError("Invalid callback URL"); - setSubmitting(false); - return; - } - if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1") { - setError("Invalid callback URL"); - setSubmitting(false); - return; - } - } catch { + if (!validateCliCallback(cliCallback)) { setError("Invalid callback URL"); setSubmitting(false); return; } const { token } = await api.verifyCode(email, value); const cliState = searchParams.get("cli_state") || ""; - const separator = cliCallback.includes("?") ? "&" : "?"; - window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`; + redirectToCliCallback(cliCallback, token, cliState); return; } @@ -126,6 +169,46 @@ function LoginPageContent() { } }; + // CLI confirm step: user is already logged in, just authorize. + if (step === "cli_confirm" && existingUser) { + return ( +
+ + + Authorize CLI + + Allow the CLI to access Multica as{" "} + + {existingUser.email} + + ? + + + + + + + +
+ ); + } + if (step === "code") { return (
From b9d2b8885287e05f3a5744dab1ca96c0699b1b4b Mon Sep 17 00:00:00 2001 From: yushen Date: Thu, 26 Mar 2026 16:39:57 +0800 Subject: [PATCH 03/20] refactor(daemon): remove pairing flow, use CLI auth token instead The daemon now reads the auth token from ~/.multica/config.json (set by `multica auth login`) instead of requiring a browser-based pairing flow. If not authenticated, it logs a message and exits. Workspace ID is auto-resolved from the user's workspaces when not explicitly set via flag/env. Removed: daemon.json, pairing session flow, --config-path flag, PairingSession type, PersistedConfig, LoadWorkspaceIDFromDaemonConfig. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/cmd/multica/cmd_agent.go | 6 +- server/cmd/multica/cmd_daemon.go | 2 - server/internal/cli/config.go | 20 ----- server/internal/daemon/client.go | 52 +++++------ server/internal/daemon/config.go | 69 +-------------- server/internal/daemon/daemon.go | 143 ++++++++++++++----------------- server/internal/daemon/types.go | 21 ----- 7 files changed, 95 insertions(+), 218 deletions(-) diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index dc491337..b8ef19be 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -87,11 +87,7 @@ func resolveWorkspaceID(cmd *cobra.Command) string { return val } cfg, _ := cli.LoadCLIConfig() - if cfg.WorkspaceID != "" { - return cfg.WorkspaceID - } - // Fallback: try daemon.json for workspace_id - return cli.LoadWorkspaceIDFromDaemonConfig() + return cfg.WorkspaceID } func runAgentList(cmd *cobra.Command, _ []string) error { diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index d1ffc8ff..f0e6184c 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -22,7 +22,6 @@ var daemonCmd = &cobra.Command{ func init() { f := daemonCmd.Flags() - f.String("config-path", "", "Path to daemon config file (env: MULTICA_DAEMON_CONFIG)") f.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)") f.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)") f.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)") @@ -35,7 +34,6 @@ func runDaemon(cmd *cobra.Command, _ []string) error { overrides := daemon.Overrides{ ServerURL: cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", ""), WorkspaceID: cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", ""), - ConfigPath: flagString(cmd, "config-path"), DaemonID: flagString(cmd, "daemon-id"), DeviceName: flagString(cmd, "device-name"), RuntimeName: flagString(cmd, "runtime-name"), diff --git a/server/internal/cli/config.go b/server/internal/cli/config.go index 83b853f3..7aec4c5d 100644 --- a/server/internal/cli/config.go +++ b/server/internal/cli/config.go @@ -46,26 +46,6 @@ func LoadCLIConfig() (CLIConfig, error) { return cfg, nil } -// LoadWorkspaceIDFromDaemonConfig reads workspace_id from ~/.multica/daemon.json -// as a fallback when it's not set in the CLI config. -func LoadWorkspaceIDFromDaemonConfig() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - data, err := os.ReadFile(filepath.Join(home, ".multica/daemon.json")) - if err != nil { - return "" - } - var cfg struct { - WorkspaceID string `json:"workspace_id"` - } - if json.Unmarshal(data, &cfg) != nil { - return "" - } - return cfg.WorkspaceID -} - // SaveCLIConfig writes the CLI config to disk. func SaveCLIConfig(cfg CLIConfig) error { path, err := CLIConfigPath() diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index bd060987..26540fbe 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "time" ) @@ -37,9 +36,16 @@ func isWorkspaceNotFoundError(err error) bool { return strings.Contains(strings.ToLower(reqErr.Body), "workspace not found") } +// Workspace represents a workspace from the API. +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` +} + // Client handles HTTP communication with the Multica server daemon API. type Client struct { baseURL string + token string client *http.Client } @@ -51,6 +57,20 @@ func NewClient(baseURL string) *Client { } } +// SetToken sets the auth token for authenticated requests. +func (c *Client) SetToken(token string) { + c.token = token +} + +// ListWorkspaces fetches the user's workspaces using the auth token. +func (c *Client) ListWorkspaces(ctx context.Context) ([]Workspace, error) { + var ws []Workspace + if err := c.getJSON(ctx, "/api/workspaces", &ws); err != nil { + return nil, err + } + return ws, nil +} + func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) { var resp struct { Task *Task `json:"task"` @@ -61,30 +81,6 @@ func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) return resp.Task, nil } -func (c *Client) CreatePairingSession(ctx context.Context, req map[string]string) (PairingSession, error) { - var resp PairingSession - if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil { - return PairingSession{}, err - } - return resp, nil -} - -func (c *Client) GetPairingSession(ctx context.Context, token string) (PairingSession, error) { - var resp PairingSession - if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil { - return PairingSession{}, err - } - return resp, nil -} - -func (c *Client) ClaimPairingSession(ctx context.Context, token string) (PairingSession, error) { - var resp PairingSession - if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil { - return PairingSession{}, err - } - return resp, nil -} - func (c *Client) StartTask(ctx context.Context, taskID string) error { return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil) } @@ -142,6 +138,9 @@ func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBod return err } req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } resp, err := c.client.Do(req) if err != nil { @@ -165,6 +164,9 @@ func (c *Client) getJSON(ctx context.Context, path string, respBody any) error { if err != nil { return err } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } resp, err := c.client.Do(req) if err != nil { diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 83bcdc49..ced3d018 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -1,8 +1,6 @@ package daemon import ( - "encoding/json" - "errors" "fmt" "net/url" "os" @@ -14,7 +12,6 @@ import ( const ( DefaultServerURL = "ws://localhost:8080/ws" - DefaultDaemonConfigPath = ".multica/daemon.json" DefaultPollInterval = 3 * time.Second DefaultHeartbeatInterval = 15 * time.Second DefaultAgentTimeout = 2 * time.Hour @@ -24,8 +21,8 @@ const ( // Config holds all daemon configuration. type Config struct { ServerBaseURL string - ConfigPath string WorkspaceID string + Token string DaemonID string DeviceName string RuntimeName string @@ -43,7 +40,6 @@ type Overrides struct { ServerURL string WorkspaceID string WorkspacesRoot string - ConfigPath string PollInterval time.Duration HeartbeatInterval time.Duration AgentTimeout time.Duration @@ -65,27 +61,8 @@ func LoadConfig(overrides Overrides) (Config, error) { return Config{}, err } - // Config path - rawConfigPath := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG")) - if overrides.ConfigPath != "" { - rawConfigPath = overrides.ConfigPath - } - configPath, err := resolveDaemonConfigPath(rawConfigPath) - if err != nil { - return Config{}, err - } - - // Load persisted config - persisted, err := LoadPersistedConfig(configPath) - if err != nil { - return Config{}, err - } - - // Workspace ID: override > env > persisted + // Workspace ID: override > env (optional — resolved at runtime if empty) workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID")) - if workspaceID == "" { - workspaceID = persisted.WorkspaceID - } if overrides.WorkspaceID != "" { workspaceID = overrides.WorkspaceID } @@ -179,7 +156,6 @@ func LoadConfig(overrides Overrides) (Config, error) { return Config{ ServerBaseURL: serverBaseURL, - ConfigPath: configPath, WorkspaceID: workspaceID, DaemonID: daemonID, DeviceName: deviceName, @@ -217,44 +193,3 @@ func NormalizeServerBaseURL(raw string) (string, error) { return strings.TrimRight(u.String(), "/"), nil } -func resolveDaemonConfigPath(raw string) (string, error) { - if raw != "" { - return filepath.Abs(raw) - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve daemon config path: %w", err) - } - return filepath.Join(home, DefaultDaemonConfigPath), nil -} - -// LoadPersistedConfig reads the daemon config from disk. -func LoadPersistedConfig(path string) (PersistedConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return PersistedConfig{}, nil - } - return PersistedConfig{}, fmt.Errorf("read daemon config: %w", err) - } - var cfg PersistedConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return PersistedConfig{}, fmt.Errorf("parse daemon config: %w", err) - } - return cfg, nil -} - -// SavePersistedConfig writes the daemon config to disk. -func SavePersistedConfig(path string, cfg PersistedConfig) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("create daemon config directory: %w", err) - } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return fmt.Errorf("encode daemon config: %w", err) - } - if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { - return fmt.Errorf("write daemon config: %w", err) - } - return nil -} diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 6fd2c8b7..5f137878 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -7,10 +7,28 @@ import ( "strings" "time" + "github.com/multica-ai/multica/server/internal/cli" "github.com/multica-ai/multica/server/internal/daemon/execenv" "github.com/multica-ai/multica/server/pkg/agent" ) +// cliConfigData holds the fields we need from the CLI config. +type cliConfigData struct { + Token string + WorkspaceID string +} + +func loadCLIConfig() (cliConfigData, error) { + cfg, err := cli.LoadCLIConfig() + if err != nil { + return cliConfigData{}, err + } + return cliConfigData{ + Token: cfg.Token, + WorkspaceID: cfg.WorkspaceID, + }, nil +} + // Daemon is the local agent runtime that polls for and executes tasks. type Daemon struct { cfg Config @@ -27,21 +45,17 @@ func New(cfg Config, logger *slog.Logger) *Daemon { } } -// Run starts the daemon: pairs if needed, registers runtimes, then polls for tasks. +// Run starts the daemon: resolves auth, registers runtimes, then polls for tasks. func (d *Daemon) Run(ctx context.Context) error { agentNames := make([]string, 0, len(d.cfg.Agents)) for name := range d.cfg.Agents { agentNames = append(agentNames, name) } - d.logger.Info("starting daemon", "agents", agentNames, "workspace_id", d.cfg.WorkspaceID, "server", d.cfg.ServerBaseURL) + d.logger.Info("starting daemon", "agents", agentNames, "server", d.cfg.ServerBaseURL) - if strings.TrimSpace(d.cfg.WorkspaceID) == "" { - workspaceID, err := d.ensurePaired(ctx) - if err != nil { - return err - } - d.cfg.WorkspaceID = workspaceID - d.logger.Info("pairing completed", "workspace_id", workspaceID) + // Resolve auth token and workspace from CLI config. + if err := d.resolveAuth(ctx); err != nil { + return err } runtimes, err := d.registerRuntimes(ctx) @@ -58,6 +72,48 @@ func (d *Daemon) Run(ctx context.Context) error { return d.pollLoop(ctx, runtimeIDs) } +// resolveAuth loads the CLI auth token and workspace ID. +// If not authenticated, it waits and retries periodically until the user logs in. +func (d *Daemon) resolveAuth(ctx context.Context) error { + // If workspace ID is already set via flag/env, just need a token. + if d.cfg.WorkspaceID != "" { + if d.cfg.Token != "" { + d.client.SetToken(d.cfg.Token) + d.logger.Info("authenticated", "workspace_id", d.cfg.WorkspaceID) + return nil + } + } + + // Try loading from CLI config. + cfg, _ := loadCLIConfig() + if cfg.Token != "" { + d.client.SetToken(cfg.Token) + if d.cfg.WorkspaceID == "" && cfg.WorkspaceID != "" { + d.cfg.WorkspaceID = cfg.WorkspaceID + } + } + + if d.cfg.Token == "" && cfg.Token == "" { + d.logger.Warn("not authenticated — run 'multica auth login' to authenticate, then restart the daemon") + return fmt.Errorf("not authenticated: run 'multica auth login' first") + } + + // If we have a token but no workspace ID, fetch the user's workspaces. + if d.cfg.WorkspaceID == "" { + ws, err := d.client.ListWorkspaces(ctx) + if err != nil { + return fmt.Errorf("failed to fetch workspaces: %w (is your token valid? try 'multica auth login')", err) + } + if len(ws) == 0 { + return fmt.Errorf("no workspaces found for this account") + } + d.cfg.WorkspaceID = ws[0].ID + d.logger.Info("using workspace", "workspace_id", ws[0].ID, "name", ws[0].Name) + } + + return nil +} + func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) { var runtimes []map[string]string for name, entry := range d.cfg.Agents { @@ -94,75 +150,6 @@ func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) { return rts, nil } -func (d *Daemon) ensurePaired(ctx context.Context) (string, error) { - // Use a deterministic agent for the pairing session metadata (prefer codex for backward compat). - var firstName string - var firstEntry AgentEntry - for _, preferred := range []string{"codex", "claude"} { - if entry, ok := d.cfg.Agents[preferred]; ok { - firstName = preferred - firstEntry = entry - break - } - } - version, err := agent.DetectVersion(ctx, firstEntry.Path) - if err != nil { - return "", err - } - - session, err := d.client.CreatePairingSession(ctx, map[string]string{ - "daemon_id": d.cfg.DaemonID, - "device_name": d.cfg.DeviceName, - "runtime_name": d.cfg.RuntimeName, - "runtime_type": firstName, - "runtime_version": version, - }) - if err != nil { - return "", fmt.Errorf("create pairing session: %w", err) - } - if session.LinkURL != nil { - d.logger.Info("open this link to pair the daemon", "url", *session.LinkURL) - } else { - d.logger.Info("pairing session created", "token", session.Token) - } - - for { - select { - case <-ctx.Done(): - return "", ctx.Err() - default: - } - - current, err := d.client.GetPairingSession(ctx, session.Token) - if err != nil { - return "", fmt.Errorf("poll pairing session: %w", err) - } - - switch current.Status { - case "approved", "claimed": - if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" { - return "", fmt.Errorf("pairing session approved without workspace") - } - if err := SavePersistedConfig(d.cfg.ConfigPath, PersistedConfig{ - WorkspaceID: strings.TrimSpace(*current.WorkspaceID), - }); err != nil { - return "", err - } - if current.Status != "claimed" { - if _, err := d.client.ClaimPairingSession(ctx, current.Token); err != nil { - return "", fmt.Errorf("claim pairing session: %w", err) - } - } - return strings.TrimSpace(*current.WorkspaceID), nil - case "expired": - return "", fmt.Errorf("pairing session expired before approval") - } - - if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { - return "", err - } - } -} func (d *Daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) { ticker := time.NewTicker(d.cfg.HeartbeatInterval) diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index 47bdb9f1..e36d581d 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -14,27 +14,6 @@ type Runtime struct { Status string `json:"status"` } -// PairingSession represents a daemon pairing session. -type PairingSession struct { - Token string `json:"token"` - DaemonID string `json:"daemon_id"` - DeviceName string `json:"device_name"` - RuntimeName string `json:"runtime_name"` - RuntimeType string `json:"runtime_type"` - RuntimeVersion string `json:"runtime_version"` - WorkspaceID *string `json:"workspace_id"` - Status string `json:"status"` - ApprovedAt *string `json:"approved_at"` - ClaimedAt *string `json:"claimed_at"` - ExpiresAt string `json:"expires_at"` - LinkURL *string `json:"link_url"` -} - -// PersistedConfig is the JSON structure saved to ~/.multica/daemon.json. -type PersistedConfig struct { - WorkspaceID string `json:"workspace_id"` -} - // Task represents a claimed task from the server. type Task struct { ID string `json:"id"` From 2cf088ddf61f135a3893b6188d5fd49e5af423e3 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:47:04 +0800 Subject: [PATCH 04/20] feat: resizable sidebar, issue detail rewrite, package consolidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add drag-to-resize sidebar with localStorage persistence - Rewrite issue detail page with Tiptap rich text editor, due date picker, acceptance criteria - Redesign create-issue modal with pill-based property toolbar and expand/collapse - Consolidate @multica/sdk and @multica/types into apps/web/shared/ - Simplify auth: remove verification codes, PATs, email service (dev-only login) - Add 401 unauthorized handler to redirect expired sessions to login - Fix due date format to send full RFC3339 timestamps - Increase description editor debounce to 1500ms - Remove arbitrary Tailwind values in create-issue modal - Renumber migrations (inbox_actor 012→009), remove unused migrations - UI polish across agents, settings, inbox, knowledge-base pages Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 33 +- .../(dashboard)/_components/app-sidebar.tsx | 31 +- apps/web/app/(dashboard)/agents/page.tsx | 148 ++-- apps/web/app/(dashboard)/inbox/page.tsx | 100 +-- .../app/(dashboard)/issues/[id]/page.test.tsx | 52 +- apps/web/app/(dashboard)/issues/page.test.tsx | 2 +- .../app/(dashboard)/knowledge-base/page.tsx | 25 +- apps/web/app/(dashboard)/settings/page.tsx | 2 +- apps/web/app/globals.css | 6 + apps/web/app/pair/local/page.tsx | 2 +- .../components/common/rich-text-editor.css | 145 ++++ .../components/common/rich-text-editor.tsx | 150 ++++ apps/web/components/ui/resizable.tsx | 2 +- apps/web/components/ui/sidebar.tsx | 83 ++- apps/web/features/auth/store.ts | 2 +- apps/web/features/inbox/store.ts | 2 +- .../features/issues/components/board-card.tsx | 8 +- .../issues/components/board-column.tsx | 52 +- .../features/issues/components/board-view.tsx | 2 +- apps/web/features/issues/components/index.ts | 2 +- .../issues/components/issue-detail.tsx | 555 ++++++++++---- .../issues/components/issues-header.tsx | 6 +- .../issues/components/issues-page.tsx | 8 +- .../features/issues/components/list-row.tsx | 2 +- .../features/issues/components/list-view.tsx | 6 +- .../components/pickers/assignee-picker.tsx | 4 +- .../components/pickers/due-date-picker.tsx | 64 ++ .../issues/components/pickers/index.ts | 1 + .../components/pickers/priority-picker.tsx | 6 +- .../components/pickers/property-picker.tsx | 2 +- .../components/pickers/status-picker.tsx | 6 +- .../issues/components/priority-icon.tsx | 2 +- .../issues/components/status-icon.tsx | 2 +- apps/web/features/issues/config/priority.ts | 2 +- apps/web/features/issues/config/status.ts | 2 +- apps/web/features/issues/store.ts | 2 +- apps/web/features/issues/stores/view-store.ts | 32 +- apps/web/features/modals/create-issue.tsx | 389 ++++++++-- apps/web/features/modals/registry.tsx | 3 +- apps/web/features/realtime/hooks.ts | 2 +- apps/web/features/realtime/provider.tsx | 4 +- .../features/realtime/use-realtime-sync.ts | 4 +- .../skills/components/skills-page.tsx | 154 ++-- apps/web/features/workspace/store.ts | 2 +- apps/web/next.config.ts | 5 - apps/web/package.json | 10 +- .../web/shared/api/client.ts | 18 +- apps/web/shared/{api.ts => api/index.ts} | 8 +- .../src => apps/web/shared/api}/ws-client.ts | 8 +- .../src => apps/web/shared/types}/agent.ts | 0 .../src => apps/web/shared/types}/api.ts | 1 + .../src => apps/web/shared/types}/comment.ts | 0 .../src => apps/web/shared/types}/daemon.ts | 0 .../src => apps/web/shared/types}/events.ts | 0 .../src => apps/web/shared/types}/inbox.ts | 0 .../src => apps/web/shared/types}/index.ts | 16 +- .../src => apps/web/shared/types}/issue.ts | 0 .../web/shared/types}/workspace.ts | 0 apps/web/test/helpers.tsx | 2 +- apps/web/vitest.config.ts | 2 - e2e/fixtures.ts | 3 +- packages/sdk/package.json | 19 - packages/sdk/src/index.ts | 11 - packages/sdk/src/logger.ts | 13 - packages/sdk/tsconfig.json | 7 - packages/types/package.json | 16 - packages/types/tsconfig.json | 7 - packages/ui/src/styles/custom.css | 6 + pnpm-lock.yaml | 682 +++++++++++++++++- server/internal/cli/client.go | 4 + server/internal/handler/issue.go | 12 + server/pkg/db/generated/issue.sql.go | 32 +- server/pkg/db/queries/issue.sql | 4 +- 73 files changed, 2322 insertions(+), 673 deletions(-) create mode 100644 apps/web/components/common/rich-text-editor.css create mode 100644 apps/web/components/common/rich-text-editor.tsx create mode 100644 apps/web/features/issues/components/pickers/due-date-picker.tsx rename packages/sdk/src/api-client.ts => apps/web/shared/api/client.ts (95%) rename apps/web/shared/{api.ts => api/index.ts} (68%) rename {packages/sdk/src => apps/web/shared/api}/ws-client.ts (93%) rename {packages/types/src => apps/web/shared/types}/agent.ts (100%) rename {packages/types/src => apps/web/shared/types}/api.ts (98%) rename {packages/types/src => apps/web/shared/types}/comment.ts (100%) rename {packages/types/src => apps/web/shared/types}/daemon.ts (100%) rename {packages/types/src => apps/web/shared/types}/events.ts (100%) rename {packages/types/src => apps/web/shared/types}/inbox.ts (100%) rename {packages/types/src => apps/web/shared/types}/index.ts (74%) rename {packages/types/src => apps/web/shared/types}/issue.ts (100%) rename {packages/types/src => apps/web/shared/types}/workspace.ts (100%) delete mode 100644 packages/sdk/package.json delete mode 100644 packages/sdk/src/index.ts delete mode 100644 packages/sdk/src/logger.ts delete mode 100644 packages/sdk/tsconfig.json delete mode 100644 packages/types/package.json delete mode 100644 packages/types/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index f0e09605..b6d94bfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,21 +12,20 @@ Multica is an AI-native task management platform — like Linear, but with AI ag ## Architecture -**Polyglot monorepo** — Go backend + TypeScript frontend. +**Go backend + standalone Next.js frontend.** - `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time) -- `apps/web/` — Next.js 16 frontend (App Router) -- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils) +- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies ### Web App Structure (`apps/web/`) -The frontend uses a **feature-based architecture** with three layers: +The frontend uses a **feature-based architecture** with four layers: ``` apps/web/ ├── app/ # Routing layer (thin shells — import from features/) ├── features/ # Business logic, organized by domain -├── shared/ # Cross-feature utilities (api client) +├── shared/ # Cross-feature utilities (api client, types, logger) ``` **`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`. @@ -41,7 +40,10 @@ apps/web/ | `features/inbox/` | Inbox notification state | `useInboxStore` | | `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` | -**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton). +**`shared/`** — Code used across multiple features: +- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton. +- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types. +- `shared/logger.ts` — Logger utility. ### State Management @@ -61,6 +63,7 @@ apps/web/ Use `@/` alias (maps to `apps/web/`): ```typescript import { api } from "@/shared/api"; +import type { Issue } from "@/shared/types"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { useIssueStore } from "@/features/issues"; @@ -74,8 +77,8 @@ Within a feature, use relative imports. Between features or to shared, use `@/`. ### Data Flow ``` -Browser → ApiClient (SDK) → REST API (Chi handlers) → sqlc queries → PostgreSQL -Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService +Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL +Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService ``` ### Backend Structure (`server/`) @@ -91,12 +94,6 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe - **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`. - **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model). -### Key Packages - -- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here. -- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*). -- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown. - ### Multi-tenancy All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace. @@ -117,7 +114,7 @@ make db-down # Stop the shared PostgreSQL container # Frontend pnpm install pnpm dev:web # Next.js dev server (port 3000) -pnpm build # Build all TS packages +pnpm build # Build frontend pnpm typecheck # TypeScript check pnpm test # TS tests (Vitest) @@ -165,10 +162,8 @@ make start-worktree # Start using .env.worktree ## 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. ActorAvatar) and shared utilities live here. -- **Feature-specific components** → `features//components/` — issue icons, pickers, and other domain-bound UI live inside their feature module, not in `packages/ui`. +- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`. +- **Feature-specific components** → `features//components/` — issue icons, pickers, and other domain-bound UI live inside their feature module. - Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`). - Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context. - Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency. diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index c3caca71..101b59a2 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -13,7 +13,6 @@ import { Plus, Check, Sparkles, - Search, SquarePen, } from "lucide-react"; import { WorkspaceAvatar } from "@/features/workspace"; @@ -26,6 +25,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarRail, } from "@/components/ui/sidebar"; import { DropdownMenu, @@ -155,25 +155,15 @@ export function AppSidebar() { -
- - - - - Search - - - useModalStore.getState().open("create-issue")} - > - - - New issue - -
+ + useModalStore.getState().open("create-issue")} + > + + + New issue +
@@ -228,6 +218,7 @@ export function AppSidebar() { + ); } diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index d27e3ee4..c020587b 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { useDefaultLayout } from "react-resizable-panels"; import { Bot, Cloud, @@ -32,7 +33,7 @@ import type { RuntimeDevice, CreateAgentRequest, UpdateAgentRequest, -} from "@multica/types"; +} from "@/shared/types"; import { Dialog, DialogContent, @@ -41,6 +42,11 @@ import { DialogDescription, DialogFooter, } from "@/components/ui/dialog"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -1134,6 +1140,9 @@ export default function AgentsPage() { const [selectedId, setSelectedId] = useState(""); const [showCreate, setShowCreate] = useState(false); const [runtimes, setRuntimes] = useState([]); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "multica_agents_layout", + }); useEffect(() => { if (!workspace) { @@ -1191,70 +1200,81 @@ export default function AgentsPage() { } return ( -
- {/* Left column — agent list */} -
-
-

Agents

- + + + {/* Left column — agent list */} +
+
+

Agents

+ +
+ {agents.length === 0 ? ( +
+ +

No agents yet

+ +
+ ) : ( +
+ {agents.map((agent) => ( + setSelectedId(agent.id)} + /> + ))} +
+ )}
- {agents.length === 0 ? ( -
- -

No agents yet

- -
- ) : ( -
- {agents.map((agent) => ( - setSelectedId(agent.id)} - /> - ))} -
- )} -
+ - {/* Right column — agent detail */} -
- {selected ? ( - - ) : ( -
- -

Select an agent to view details

- -
- )} -
+ + + + {/* Right column — agent detail */} +
+ {selected ? ( + + ) : ( +
+ +

Select an agent to view details

+ +
+ )} +
+
{showCreate && ( )} -
+ ); } diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 32440798..e64194c7 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo } from "react"; +import { useDefaultLayout } from "react-resizable-panels"; import { useInboxStore } from "@/features/inbox"; import { IssueDetail, StatusIcon } from "@/features/issues/components"; import { ActorAvatar } from "@/components/common/actor-avatar"; @@ -13,8 +14,13 @@ import { BookCheck, ListChecks, } from "lucide-react"; -import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types"; +import type { InboxItem, InboxItemType, InboxSeverity } from "@/shared/types"; import { Button } from "@/components/ui/button"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; import { Skeleton } from "@/components/ui/skeleton"; import { DropdownMenu, @@ -82,25 +88,25 @@ function InboxListItem({ />
- - {item.title} - -
- {item.issue_status && ( - - )} +
{!item.read && ( - + )} + + {item.title} +
+ {item.issue_status && ( + + )}
-

+

{typeLabels[item.type] ?? item.type}

- + {timeAgo(item.created_at)}
@@ -119,6 +125,10 @@ export default function InboxPage() { const storeItems = useInboxStore((s) => s.items); const loading = useInboxStore((s) => s.loading); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "multica_inbox_layout", + }); + // Sort: severity first, then newest first const items = useMemo(() => { return [...storeItems] @@ -202,40 +212,46 @@ export default function InboxPage() { if (loading) { return ( -
-
-
- -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- - + + +
+
+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
-
- ))} + ))} +
-
-
- - -
-
+ + + +
+ + +
+
+ ); } return ( -
+ + {/* Left column — inbox list */} -
+

Inbox

{unreadCount > 0 && ( - + {unreadCount} )} @@ -280,7 +296,7 @@ export default function InboxPage() {

No notifications

) : ( -
+
{items.map((item) => ( )}
- + + + {/* Right column — detail */} -
+
{selected?.issue_id ? ( { handleArchive(selected.id); }} @@ -336,6 +353,7 @@ export default function InboxPage() {
)}
-
+ + ); } diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index c2481acb..4840cfc2 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -1,8 +1,8 @@ -import { Suspense } from "react"; +import { Suspense, forwardRef, useState, useImperativeHandle } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { Issue, Comment } from "@multica/types"; +import type { Issue, Comment } from "@/shared/types"; // Mock next/navigation vi.mock("next/navigation", () => ({ @@ -71,6 +71,39 @@ vi.mock("@/components/ui/calendar", () => ({ Calendar: () => null, })); +// Mock RichTextEditor (Tiptap needs real DOM) +vi.mock("@/components/common/rich-text-editor", () => ({ + RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => { + const [value, setValue] = useState(defaultValue || ""); + useImperativeHandle(ref, () => ({ + getMarkdown: () => value, + clearContent: () => setValue(""), + focus: () => {}, + })); + return ( +