Compare commits

..

10 Commits

Author SHA1 Message Date
Jiayuan Zhang
a6a5ef0aa8 feat(views): show issue title in detail page header
Previously the issue detail top bar only showed 'workspace name > identifier'.
Add the issue title next to the identifier so users can see what issue they're
viewing without scrolling.
2026-04-20 00:34:01 +08:00
Jiayuan Zhang
b8907dda8d fix(views): prevent infinite re-render loops in sidebar and chat resize (#1322)
* fix(sidebar): stabilize useQuery default arrays to prevent render loop

Inline `= []` defaults on `useQuery` return a new array reference on
every render when `data` is undefined (query disabled or mid-load).
Downstream effects/memos that depend on the value then fire every
render; the pinned-items `useEffect` compounds this by calling
`setLocalPinned` each time, so under sustained `data === undefined`
(e.g. backend unreachable, WebSocket in reconnect loop) React trips
its "Maximum update depth exceeded" guard and the sidebar becomes
unusable.

Use module-level empty-array constants so the default identity stays
stable across renders.

* fix(chat): short-circuit ResizeObserver update when bounds unchanged

The resize observer always called `setRevision(r => r + 1)` from its
callback, even when `clientWidth`/`clientHeight` were identical to the
previous reading. Any spurious notification — sub-pixel layout jitter
during mount, or an ancestor reflow triggered by an unrelated state
update — then fed back into the same render cycle and could exceed
React's update-depth limit.

Guard the state bump by comparing against the previous bounds, and
leave `setBoundsReady(true)` outside the guard since it's idempotent.
2026-04-18 23:24:32 +08:00
Bohan Jiang
6cd49e132d docs(selfhost): clarify 888888 master code is disabled by default in Docker (#1313)
Following #1307, the Docker self-host stack defaults to APP_ENV=production,
which disables the 888888 master verification code on auth.go:169. The
installer banners and self-hosting docs still told operators to log in with
888888, leaving them stuck.

Update install.sh, install.ps1, SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md,
and self-hosting.mdx to document the three login paths: configure
RESEND_API_KEY (recommended), set APP_ENV=development to enable 888888 for
private evaluation, or read the dev verification code from backend container
logs. Also warn against enabling APP_ENV=development on public instances.
2026-04-18 14:30:35 +08:00
Bohan Jiang
a6db465e46 fix(ui/agents): drop Codex-incompatible --model example from custom args tab (#1310)
* fix(agent/codex): route custom_args -m/--model to thread/start payload

Codex agents spawn via `codex app-server --listen stdio://`, which does
not accept `-m` / `--model` (those belong to the normal Codex CLI). When
a user's custom_args still carried those tokens the process exited
before the JSON-RPC initialize handshake with `codex process exited`,
with no actionable error.

Extract `-m <v>`, `--model <v>`, and `--model=<v>` from opts.CustomArgs
before invoking app-server and promote the value into opts.Model, so
that startOrResumeThread can pass it through the `thread/start` payload
where Codex actually reads the field.

Fixes #1308.

* fix(ui/agents): drop Codex-incompatible --model example from custom args tab

The helper text and placeholder suggested `--model claude-sonnet-4-…` as
a custom CLI argument, which is valid for Claude but crashes Codex
agents (its `app-server` subcommand does not accept model flags). Swap
in provider-agnostic copy so the UI no longer steers users into an
invalid configuration for non-Claude runtimes.

Refs #1308.

* revert "fix(agent/codex): route custom_args -m/--model to thread/start payload"

This reverts f18355b2. After review, extracting `-m`/`--model` out of
opts.CustomArgs and promoting them into the thread/start payload is the
wrong shape of fix: agent CLIs have many flags their non-interactive
modes don't accept, and hand-translating a subset case-by-case doesn't
scale — it pushes us toward an ever-growing list of per-backend arg
rewriters.

The preferred direction is to teach users via the UI what command their
custom_args extend (see the launch_header preview in #1312) and let
bad configurations fail loudly. If the resulting error is hard to read
that's a separate improvement we should make on the failure path, not
by silently rewriting user input.

Refs #1308.
2026-04-18 14:25:58 +08:00
Kagura
965561a6cc fix(selfhost): pass APP_ENV to backend container, default to production (#1307) 2026-04-18 14:25:23 +08:00
Bohan Jiang
163f34f918 feat(agents): show launch mode preview in custom args tab (#1312)
* feat(agent): add LaunchHeader per agent type

Each backend in server/pkg/agent/ hardcodes a stable command skeleton
(e.g. `codex app-server --listen stdio://`, `hermes acp`) before
appending opts.CustomArgs. Surfacing that skeleton lets the UI tell
users which command their custom_args are being appended to, so a
Codex user doesn't mistakenly add `-m gpt-5.4-mini` expecting it to
reach the CLI when the subcommand is actually `app-server`.

Expose only the minimum that aids judgment — binary + subcommand, or a
short mode label when there is no subcommand — and deliberately omit
transport values, internal flags, and env to keep the surface small
and renaming-safe.

Refs #1308.

* feat(handler/runtime): surface launch_header on runtime response

runtimeToResponse now derives launch_header from agent.LaunchHeader,
piggybacking on the runtime's existing provider field so the
frontend's RuntimeDevice gains the skeleton without a new endpoint or
DB query. Client gets the header for free whenever it lists agents'
runtimes — which the custom-args tab already does.

Refs #1308.

* feat(ui/agents): show launch mode preview in custom args tab

Thread the resolved RuntimeDevice from AgentDetail into CustomArgsTab
and render its launch_header as a one-line preview above the args
list, so users see `codex app-server <your args>` (or equivalent per
provider) and can tell whether a CLI-style flag like `--model` will
actually reach the invoked subcommand. Source of truth stays in the
Go backend; the TS type just carries the string.

Refs #1308.
2026-04-18 14:18:42 +08:00
Bohan Jiang
2317533da4 fix(auth): validate next= redirect target to prevent open redirect (#1309)
* refactor(auth): add sanitizeNextUrl helper in @multica/core/auth

Extracts a reusable helper that returns a post-login redirect URL only
when it's a safe single-slash relative path, and null otherwise. Rejects
absolute URLs, protocol-relative URLs, backslashes, and control
characters so call sites can safely pass the result to router.push().

Keeping the rule in a single helper (with direct unit tests) avoids
each consumer re-implementing the validation and drifting.

* fix(auth): validate next= redirect target to prevent open redirect

Closes #1116

Next.js router.push accepts absolute URLs, so a crafted
`/login?next=https://evil.example` would send the user off-origin
after a successful login. The Google OAuth callback has the same
vector via the `state=next:<url>` payload.

Sanitize both entry points through `sanitizeNextUrl` from
`@multica/core/auth` so only safe single-slash relative paths survive;
null results fall through to the existing workspace-list-based default
without any hard-coded path.

---------

Co-authored-by: JunghwanNA <70629228+shaun0927@users.noreply.github.com>
2026-04-18 13:24:01 +08:00
niceSprite
d81e6a14a6 fix(comment): assignee on_comment path should use reply id, not thread root (#1302)
* fix(comment): assignee on_comment path should use reply id, not thread root

Symmetric fix to #871 — that PR fixed the @mention path but missed the
assignee on_comment path in the same file. Replies on agent-assigned
issues were still getting trigger_comment_id = parent_id, so the daemon
fed the parent comment's content to the resumed claude session, which
then either exited with 'Already replied to comment <parent>' or silently
misrouted its answer depending on model / session state.

Reply placement (flat-thread grouping) is already decoupled from
trigger_comment_id by TaskService.createAgentComment's parent
normalization (added alongside #871), so passing comment.ID directly is
safe and matches the mention path's post-#871 behavior.

Fixes #1301

Made-with: Cursor

* test(comment): assert assignee on_comment records reply id as trigger_comment_id

Integration regression guard for #1301. Asserts that after a member posts
a reply under an agent-authored thread, the enqueued agent task's
trigger_comment_id matches the new reply, not the thread root. Without
the companion fix in comment.go the old parent-override would store the
root id and the daemon would feed stale content (via prompt.go
BuildPrompt) to the agent.

Made-with: Cursor

---------

Co-authored-by: fuxiao <fuxiao@zyql.com>
2026-04-18 13:20:11 +08:00
Bohan Jiang
e198a67f8f docs(prompt): warn agents that mention syntax is an action, not a text reference (#1306)
Agent mentions enqueue a new task; member mentions send a notification.
Without this warning, agents have used `[@Name](mention://agent/<id>)` in
prose (e.g. "GPT-Boy is correct") and accidentally re-triggered the agent.

Adds a caveat under `## Mentions` in the prompt injected into agent
runtimes, plus tightens the Agent bullet to make the side-effect explicit.
2026-04-18 13:09:07 +08:00
Bohan Jiang
0ed16fc1b1 fix(autopilots): spin the Loader2 icon while a run is in progress (#1305)
The autopilot detail page mapped `status: "running"` to a `Loader2` icon
but rendered it without `animate-spin`, so a manually-triggered run sat
on a static circle until the row flipped to completed/failed and the
user got no visual feedback that anything was happening.

Add an optional `spin: true` flag to the run-status config and apply
`animate-spin` when set. Only the running entry is marked.
2026-04-18 13:05:52 +08:00
25 changed files with 363 additions and 68 deletions

View File

@@ -26,7 +26,7 @@ multica setup self-host
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
Open http://localhost:3000, log in with any email + verification code **`888888`**.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -63,9 +63,13 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
### Step 3 — Install CLI & Start Daemon

View File

@@ -23,7 +23,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
### Google OAuth (Optional)

View File

@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 log in with any email + code **`888888`**.
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
@@ -64,10 +64,14 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
### Step 2 — Log In
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
<Callout>
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
</Callout>
### Step 3 — Install CLI & Start Daemon

View File

@@ -24,8 +24,14 @@ vi.mock("next/navigation", () => ({
}));
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
// web wrapper uses useAuthStore((s) => s.user/isLoading)
vi.mock("@multica/core/auth", () => {
// web wrapper uses useAuthStore((s) => s.user/isLoading). Keep the real
// sanitizeNextUrl so the redirect-sanitization rules are exercised rather
// than silently drifting behind a mock reimplementation.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
const authState = {
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
@@ -36,7 +42,7 @@ vi.mock("@multica/core/auth", () => {
(selector: (s: typeof authState) => unknown) => selector(authState),
{ getState: () => authState },
);
return { useAuthStore };
return { ...actual, useAuthStore };
});
// Mock auth-cookie

View File

@@ -3,7 +3,7 @@
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import type { Workspace } from "@multica/core/types";
@@ -25,8 +25,9 @@ function LoginPageContent() {
// `next` carries a protected URL the user was originally headed to
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
// "/issues" default — if `next` is absent we decide after login based on
// the user's workspace list.
const nextUrl = searchParams.get("next");
// the user's workspace list. Sanitize first so a crafted `?next=https://evil`
// cannot bounce the user off-origin after a successful login.
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
// Already authenticated — honor ?next= or fall back to first workspace
// (or /workspaces/new if the user has none). Skip this entire path when

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { paths } from "@multica/core/paths";
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ setQueryData: vi.fn() }),
}));
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
// exercised rather than silently diverging from the source of truth.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
return {
...actual,
useAuthStore: (selector: (s: unknown) => unknown) =>
selector({ loginWithGoogle: mockLoginWithGoogle }),
};
});
vi.mock("@multica/core/workspace/queries", () => ({
workspaceKeys: { list: () => ["workspaces"] },
}));
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: mockListWorkspaces,
googleLogin: vi.fn(),
},
}));
import CallbackPage from "./page";
describe("CallbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(undefined);
mockListWorkspaces.mockResolvedValue([]);
});
it("falls back to paths.newWorkspace() when no next= is present and the user has no workspace", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
});
});
it("ignores unsafe next= targets from the OAuth state and still lands on the default destination", async () => {
mockSearchParams.set("state", "next:https://evil.example");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
});
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
});
it("honors a safe next= target (e.g. /invite/{id})", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
});
});

View File

@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { api } from "@multica/core/api";
@@ -42,7 +42,9 @@ function CallbackContent() {
const stateParts = state.split(",");
const isDesktop = stateParts.includes("platform:desktop");
const nextPart = stateParts.find((p) => p.startsWith("next:"));
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
// Strip "next:" prefix, then drop anything that isn't a safe relative path
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
const redirectUri = `${window.location.origin}/auth/callback`;

View File

@@ -56,6 +56,7 @@ services:
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
restart: unless-stopped

View File

@@ -1,5 +1,6 @@
export { createAuthStore } from "./store";
export type { AuthStoreOptions, AuthState } from "./store";
export { sanitizeNextUrl } from "./utils";
import type { createAuthStore as CreateAuthStoreFn } from "./store";

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { sanitizeNextUrl } from "./utils";
describe("sanitizeNextUrl", () => {
it("accepts single-slash relative paths", () => {
expect(sanitizeNextUrl("/issues")).toBe("/issues");
expect(sanitizeNextUrl("/invite/123")).toBe("/invite/123");
expect(sanitizeNextUrl("/issues?tab=assigned#top")).toBe(
"/issues?tab=assigned#top",
);
});
it("returns null for null or empty input", () => {
expect(sanitizeNextUrl(null)).toBeNull();
expect(sanitizeNextUrl("")).toBeNull();
});
it("rejects absolute URLs", () => {
expect(sanitizeNextUrl("https://evil.example")).toBeNull();
expect(sanitizeNextUrl("http://evil.example/path")).toBeNull();
});
it("rejects javascript: and other non-http schemes", () => {
// Caught by the leading-slash rule, but named here so future edits
// to the regex don't silently drop protection against this vector.
expect(sanitizeNextUrl("javascript:alert(1)")).toBeNull();
expect(sanitizeNextUrl("data:text/html,<script>")).toBeNull();
});
it("rejects protocol-relative URLs", () => {
expect(sanitizeNextUrl("//evil.example")).toBeNull();
expect(sanitizeNextUrl("//evil.example/path")).toBeNull();
});
it("rejects paths containing backslashes", () => {
expect(sanitizeNextUrl("/\\evil.example")).toBeNull();
expect(sanitizeNextUrl("\\\\evil.example")).toBeNull();
});
it("rejects paths containing control characters", () => {
expect(sanitizeNextUrl("/safe\u0000bad")).toBeNull();
expect(sanitizeNextUrl("/safe\tbad")).toBeNull();
expect(sanitizeNextUrl("/safe\r\nbad")).toBeNull();
});
});

View File

@@ -0,0 +1,20 @@
/**
* Validate a post-login redirect URL and return it only if safe to follow.
*
* Only single-slash relative paths (e.g. `/invite/abc`) are accepted. Returns
* `null` for unsafe or empty input — call sites decide the fallback so this
* helper never overloads a specific path with "user did not pass next".
*
* Rejects:
* - `null` / empty string
* - absolute URLs (`https://evil.com`, `javascript:alert(1)`, …)
* - protocol-relative URLs (`//evil.com`)
* - paths containing backslashes (Windows-style or `/\\host`)
* - paths containing ASCII control characters (`\x00``\x1f`)
*/
export function sanitizeNextUrl(raw: string | null): string | null {
if (!raw) return null;
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
if (/[\x00-\x1f\\]/.test(raw)) return null;
return raw;
}

View File

@@ -11,6 +11,7 @@ export interface RuntimeDevice {
name: string;
runtime_mode: AgentRuntimeMode;
provider: string;
launch_header: string;
status: "online" | "offline";
device_info: string;
metadata: Record<string, unknown>;

View File

@@ -178,6 +178,7 @@ export function AgentDetail({
{activeTab === "custom_args" && (
<CustomArgsTab
agent={agent}
runtimeDevice={runtimeDevice}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}

View File

@@ -7,7 +7,7 @@ import {
Plus,
Trash2,
} from "lucide-react";
import type { Agent } from "@multica/core/types";
import type { Agent, RuntimeDevice } from "@multica/core/types";
import { createSafeId } from "@multica/core/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
@@ -29,9 +29,11 @@ function entriesToArgs(entries: ArgEntry[]): string[] {
export function CustomArgsTab({
agent,
runtimeDevice,
onSave,
}: {
agent: Agent;
runtimeDevice?: RuntimeDevice;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [entries, setEntries] = useState<ArgEntry[]>(
@@ -69,6 +71,8 @@ export function CustomArgsTab({
}
};
const launchHeader = runtimeDevice?.launch_header;
return (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">
@@ -77,9 +81,17 @@ export function CustomArgsTab({
Custom Arguments
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Additional CLI arguments appended to the agent command at launch
(e.g. --model claude-sonnet-4-20250514)
Additional CLI arguments appended to the agent command at launch.
Supported flags depend on the agent's CLI.
</p>
{launchHeader && (
<p className="mt-2 text-xs text-muted-foreground">
Launch mode:{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
{launchHeader} &lt;your args&gt;
</code>
</p>
)}
</div>
<Button
type="button"
@@ -99,7 +111,7 @@ export function CustomArgsTab({
<Input
value={entry.value}
onChange={(e) => updateEntry(index, e.target.value)}
placeholder="--model claude-sonnet-4-20250514"
placeholder="--flag value"
className="flex-1 font-mono text-xs"
/>
<button

View File

@@ -52,9 +52,9 @@ function formatDate(date: string): string {
});
}
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2; spin?: boolean }> = {
issue_created: { label: "Issue Created", color: "text-blue-500", icon: Clock },
running: { label: "Running", color: "text-blue-500", icon: Loader2 },
running: { label: "Running", color: "text-blue-500", icon: Loader2, spin: true },
completed: { label: "Completed", color: "text-emerald-500", icon: CheckCircle2 },
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
};
@@ -66,7 +66,7 @@ function RunRow({ run }: { run: AutopilotRun }) {
const content = (
<>
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color)} />
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
<span className={cn("w-24 shrink-0 text-xs font-medium", cfg.color)}>{cfg.label}</span>
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">

View File

@@ -34,11 +34,16 @@ export function useChatResize(
if (!parent) return;
const update = () => {
boundsRef.current = {
maxW: Math.floor(parent.clientWidth * MAX_RATIO),
maxH: Math.floor(parent.clientHeight * MAX_RATIO),
};
setBoundsReady(true);
const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
setBoundsReady(true); // idempotent once true
// Only trigger a re-render if the bounds actually changed. Without this
// guard, any spurious ResizeObserver notification (including sub-pixel
// layout jitter during mount) schedules a setState that feeds back into
// the observer, producing "Maximum update depth exceeded".
const prev = boundsRef.current;
if (prev.maxW === maxW && prev.maxH === maxH) return;
boundsRef.current = { maxW, maxH };
setRevision((r) => r + 1);
};

View File

@@ -697,9 +697,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
<span className="shrink-0">
<span className="shrink-0 text-muted-foreground">
{issue.identifier}
</span>
<span className="truncate font-medium text-foreground">
{issue.title}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Tooltip>

View File

@@ -78,6 +78,16 @@ import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import type { PinnedItem } from "@multica/core/types";
import { useLogout } from "../auth";
// Stable empty arrays for query defaults. Using an inline `= []` default on
// `useQuery` creates a new array reference on every render when `data` is
// undefined (e.g. query disabled or loading) — which in turn breaks any
// `useEffect`/`useMemo` that depends on the value, and can trigger infinite
// re-render loops when the effect itself calls `setState`.
const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
// Only parameterless paths are valid nav destinations.
@@ -202,11 +212,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
const logout = useLogout();
const workspace = useCurrentWorkspace();
const p = useWorkspacePaths();
const { data: workspaces = [] } = useQuery(workspaceListOptions());
const { data: myInvitations = [] } = useQuery(myInvitationListOptions());
const { data: workspaces = EMPTY_WORKSPACES } = useQuery(workspaceListOptions());
const { data: myInvitations = EMPTY_INVITATIONS } = useQuery(myInvitationListOptions());
const wsId = workspace?.id;
const { data: inboxItems = [] } = useQuery({
const { data: inboxItems = EMPTY_INBOX } = useQuery({
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
queryFn: () => api.listInbox(),
enabled: !!wsId,
@@ -216,7 +226,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = [] } = useQuery({
const { data: pinnedItems = EMPTY_PINS } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
enabled: !!wsId && !!userId,
});

View File

@@ -309,7 +309,8 @@ function Start-LocalInstall {
Write-Host ""
Write-Host " multica setup self-host " -NoNewline; Write-Host "# Configure + authenticate + start daemon" -ForegroundColor DarkGray
Write-Host ""
Write-Host " Default verification code: 888888"
Write-Host " Login: configure RESEND_API_KEY in .env for email codes,"
Write-Host " or set APP_ENV=development in .env to enable the dev master code 888888."
Write-Host ""
Write-Host " To stop all services:"
Write-Host ' $env:MULTICA_MODE="stop"; irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex'

View File

@@ -337,7 +337,8 @@ run_with_server() {
printf "\n"
printf " ${CYAN}multica setup self-host${RESET} # Configure + authenticate + start daemon\n"
printf "\n"
printf " Default verification code: ${BOLD}888888${RESET}\n"
printf " ${BOLD}Login:${RESET} configure ${CYAN}RESEND_API_KEY${RESET} in .env for email codes,\n"
printf " or set ${CYAN}APP_ENV=development${RESET} in .env to enable the dev master code ${BOLD}888888${RESET}.\n"
printf "\n"
printf " ${BOLD}To stop all services:${RESET}\n"
printf " curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop\n"

View File

@@ -58,6 +58,27 @@ func clearTasks(t *testing.T, issueID string) {
}
}
// latestTriggerCommentID returns the trigger_comment_id of the most recently
// created queued/dispatched task for the given issue, or empty string if none.
func latestTriggerCommentID(t *testing.T, issueID string) string {
t.Helper()
var triggerID *string
err := testPool.QueryRow(context.Background(),
`SELECT trigger_comment_id::text
FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('queued', 'dispatched')
ORDER BY created_at DESC
LIMIT 1`,
issueID).Scan(&triggerID)
if err != nil {
t.Fatalf("failed to fetch trigger_comment_id: %v", err)
}
if triggerID == nil {
return ""
}
return *triggerID
}
// getAgentID returns the ID of the first agent in the test workspace.
func getAgentID(t *testing.T) string {
t.Helper()
@@ -228,6 +249,25 @@ func TestCommentTriggerOnComment(t *testing.T) {
}
})
// Regression guard for #1301: the assignee on_comment path must record
// the NEW reply as trigger_comment_id, not the thread root. Otherwise
// the daemon feeds stale content to the agent prompt, which with
// `--resume` sessions surfaces as "already replied, no further action".
// Reply placement (flat-thread grouping) is handled downstream in
// TaskService.createAgentComment, not here.
t.Run("reply records new comment id (not thread root) as trigger_comment_id", func(t *testing.T) {
clearTasks(t, issueID)
threadID := postCommentAsAgent(t, issueID, "First pass analysis.", agentID, nil)
replyID := postComment(t, issueID, "Please also check the edge case", strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Fatalf("expected 1 pending task, got %d", n)
}
if got := latestTriggerCommentID(t, issueID); got != replyID {
t.Errorf("trigger_comment_id = %q, want reply id %q (thread root was %q)",
got, replyID, threadID)
}
})
t.Run("reply to member thread without mentions suppresses trigger", func(t *testing.T) {
clearTasks(t, issueID)
// Member starts a thread.

View File

@@ -263,14 +263,12 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
// Resolve thread root: if the comment is a reply, agent should reply
// to the thread root (matching frontend behavior where all replies
// in a thread share the same top-level parent).
replyTo := comment.ID
if comment.ParentID.Valid {
replyTo = comment.ParentID
}
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, replyTo); err != nil {
// Always use the current comment as the trigger so the agent reads
// the actual new reply, not the thread root. Reply placement (flat
// thread grouping) is handled downstream by createAgentComment,
// which resolves parent_id to the thread root before posting. This
// mirrors the mention path's behavior (see enqueueMentionedAgentTasks).
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, comment.ID); err != nil {
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
}
}

View File

@@ -9,24 +9,26 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/pkg/agent"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
type AgentRuntimeResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
DaemonID *string `json:"daemon_id"`
Name string `json:"name"`
RuntimeMode string `json:"runtime_mode"`
Provider string `json:"provider"`
Status string `json:"status"`
DeviceInfo string `json:"device_info"`
Metadata any `json:"metadata"`
OwnerID *string `json:"owner_id"`
LastSeenAt *string `json:"last_seen_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
DaemonID *string `json:"daemon_id"`
Name string `json:"name"`
RuntimeMode string `json:"runtime_mode"`
Provider string `json:"provider"`
LaunchHeader string `json:"launch_header"`
Status string `json:"status"`
DeviceInfo string `json:"device_info"`
Metadata any `json:"metadata"`
OwnerID *string `json:"owner_id"`
LastSeenAt *string `json:"last_seen_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
@@ -39,19 +41,20 @@ func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
}
return AgentRuntimeResponse{
ID: uuidToString(rt.ID),
WorkspaceID: uuidToString(rt.WorkspaceID),
DaemonID: textToPtr(rt.DaemonID),
Name: rt.Name,
RuntimeMode: rt.RuntimeMode,
Provider: rt.Provider,
Status: rt.Status,
DeviceInfo: rt.DeviceInfo,
Metadata: metadata,
OwnerID: uuidToPtr(rt.OwnerID),
LastSeenAt: timestampToPtr(rt.LastSeenAt),
CreatedAt: timestampToString(rt.CreatedAt),
UpdatedAt: timestampToString(rt.UpdatedAt),
ID: uuidToString(rt.ID),
WorkspaceID: uuidToString(rt.WorkspaceID),
DaemonID: textToPtr(rt.DaemonID),
Name: rt.Name,
RuntimeMode: rt.RuntimeMode,
Provider: rt.Provider,
LaunchHeader: agent.LaunchHeader(rt.Provider),
Status: rt.Status,
DeviceInfo: rt.DeviceInfo,
Metadata: metadata,
OwnerID: uuidToPtr(rt.OwnerID),
LastSeenAt: timestampToPtr(rt.LastSeenAt),
CreatedAt: timestampToString(rt.CreatedAt),
UpdatedAt: timestampToString(rt.UpdatedAt),
}
}

View File

@@ -125,3 +125,28 @@ func New(agentType string, cfg Config) (Backend, error) {
func DetectVersion(ctx context.Context, executablePath string) (string, error) {
return detectCLIVersion(ctx, executablePath)
}
// launchHeaders maps each supported agent type to the user-visible skeleton
// that the daemon spawns before any custom_args are appended. This is
// intentionally minimal — only the command + subcommand (or a short mode
// label when there is no subcommand). Internal flags, transport values, and
// environment variables are deliberately omitted so the string is a hint
// about *what* users are extending, not a dump of the full command line.
var launchHeaders = map[string]string{
"claude": "claude (stream-json)",
"codex": "codex app-server",
"copilot": "copilot (json)",
"cursor": "cursor-agent (stream-json)",
"gemini": "gemini (stream-json)",
"hermes": "hermes acp",
"openclaw": "openclaw agent (json)",
"opencode": "opencode run (json)",
"pi": "pi (json mode)",
}
// LaunchHeader returns the user-visible launch skeleton for agentType, or an
// empty string if the type is unknown. Callers render this as a preview so
// users understand which command their custom_args get appended to.
func LaunchHeader(agentType string) string {
return launchHeaders[agentType]
}

View File

@@ -62,3 +62,28 @@ func TestDetectVersionFailsForMissingBinary(t *testing.T) {
t.Fatal("expected error for missing binary")
}
}
func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
t.Parallel()
// The factory in New() enumerates every supported agent type; LaunchHeader
// must stay in sync so the UI preview never shows an empty skeleton for a
// runtime the daemon actually spawns. If a new backend is added, add an
// entry to launchHeaders in agent.go and extend this list.
supported := []string{
"claude", "codex", "copilot", "cursor", "gemini",
"hermes", "openclaw", "opencode", "pi",
}
for _, t_ := range supported {
if header := LaunchHeader(t_); header == "" {
t.Errorf("LaunchHeader(%q) returned empty string — add it to launchHeaders", t_)
}
}
}
func TestLaunchHeaderReturnsEmptyForUnknownType(t *testing.T) {
t.Parallel()
if header := LaunchHeader("made-up-agent"); header != "" {
t.Errorf("expected empty header for unknown type, got %q", header)
}
}