Compare commits

...

13 Commits

Author SHA1 Message Date
Jiayuan
411fd6a5a1 fix(views): improve connect-remote dialog layout and usability
- Widen dialog from sm:max-w-lg to sm:max-w-xl for longer commands
- Add max-h-[85vh] + overflow-y-auto so content scrolls on small screens
- Split monolithic code block into 4 separate labeled steps (install,
  configure, login, start daemon) — each with its own copy button
- Make copy buttons always visible instead of hover-only
- Condense security tips into a single compact paragraph
- Tighten vertical spacing throughout
2026-04-29 17:28:41 +02:00
Jiayuan
de0d30509e feat(views): add remote machine / AWS EC2 connection wizard to Runtimes page
Add a "Connect remote machine" CTA to the Runtimes page header and
empty state that opens a 3-step wizard dialog guiding users through:

1. Installing the Multica CLI on a remote machine
2. Configuring, logging in with a PAT, and starting the daemon
3. Monitoring for runtime registration via WebSocket

Includes security tips (IAM roles, no root keys), troubleshooting
guidance (daemon status/logs, CLI version check), and post-connection
flow to create an agent on the newly registered runtime.

Closes MUL-1588
2026-04-29 15:34:22 +02:00
Multica Eve
472e78022e fix: improve quick create inbox previews (#1883)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Jiayuan Zhang <forrestchang7@gmail.com>
2026-04-29 20:56:27 +08:00
elrrrrrrr
5bf0e7022d fix(auth): route invitees to their workspace instead of forcing /onboarding (#1868)
* fix(auth): route invitees to their workspace instead of forcing /onboarding

Workspace presence now wins over `onboarded_at` across every post-auth
entry point, so a user invited into an existing workspace lands inside
that workspace instead of being trapped in the new-workspace wizard.

The redesigned onboarding flow (#1411) intentionally flipped the
priority during frontend development so every login re-entered
/onboarding; the backend `onboarded_at` field shipped but the flipped
priority was never restored. Closes #1837.

- packages/core/paths/resolve.ts: has-workspace beats !hasOnboarded.
  Onboarding is reachable only when the user has zero workspaces.
- apps/web/app/auth/callback/page.tsx: drop the early-return on
  !onboarded so a `next=/invite/<id>` survives Google OAuth round-trips.
- apps/web/app/(auth)/login/page.tsx: same removal in both the
  already-authenticated effect and the post-login handler.
- packages/views/layout/use-dashboard-guard.ts: stop bouncing in-workspace
  users to /onboarding; rely on the resolver for zero-workspace cases.
- apps/desktop/src/renderer/src/App.tsx: window-overlay now opens
  onboarding only when wsCount === 0 AND !hasOnboarded.
- apps/web/app/(auth)/onboarding/page.tsx: defense-in-depth — bounce
  away if the visitor already has a workspace, even on direct URL access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(auth): fix URLSearchParams leaking state across callback tests

The previous cleanup `mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k))`
silently skipped entries because forEach advances its index while the
underlying URLSearchParams shrinks, so a `state=next:/invite/...` set
in one test bled into the next. Snapshot keys via Array.from before
deleting. Also rewrites the assertions to match the new policy: an
unonboarded user with a safe `next=` honors it, with a workspace lands
in that workspace, and only with zero workspaces falls back to
/onboarding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:53:58 +08:00
Multica Eve
665ac39730 fix(ci): restore frontend checks (#1878)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 14:49:42 +02:00
Jiayuan Zhang
55b7e2e93a fix(views): stop showing hardcoded model name in default model display (#1875)
When no model is explicitly selected, the model dropdown and inspector
picker no longer show "Default — Claude Sonnet 4.6". Instead they show
"Default (provider)" / "Default", avoiding confusion when the actual
CLI default differs from the hardcoded catalog entry.
2026-04-29 14:18:01 +02:00
Jiayuan Zhang
80c5bb9e9e feat(views): quick capture continuous creation mode (#1863)
* feat(views): keep quick capture open after submit for continuous creation

After successfully sending a prompt to the agent, the dialog now clears
the editor and stays open instead of closing. This lets users create
multiple issues in quick succession without reopening the dialog each
time. The user can still close manually via X or Escape.

* feat(views): add success feedback for quick capture continuous mode

After each successful submit, the Create button briefly flashes green
with a checkmark "✓ Sent" for 1.5s, then reverts. A persistent counter
("N sent") appears in the footer so the user knows how many prompts
they've dispatched in this session. No explicit mode toggle needed —
the counter implicitly signals continuous mode is active.

* feat(views): add "Create another" toggle to quick capture (Linear-style)

Replace always-on continuous mode with an opt-in toggle switch in the
footer, matching Linear's "Create more" pattern. The preference is
persisted per-workspace via the quick-create store so it remembers
across sessions.

- Toggle OFF (default): submit closes the dialog (original behavior)
- Toggle ON: submit clears the editor and stays open; button flashes
  green "✓ Sent" and a counter shows how many have been dispatched

* fix(views): remove stale breadcrumb identifier test

PR #1872 removed the issue identifier from the breadcrumb but the
corresponding test was not updated, causing CI to fail.
2026-04-29 14:15:14 +02:00
Jiayuan Zhang
6a665c68a3 fix(inbox): improve quick-create notification to show issue title prominently (#1873)
The inbox notification for quick-create showed "Created MUL-1577: <title>"
which truncated the actual issue title. Now the title field shows just the
issue title (the most useful info), and the detail label shows "Created
MUL-XXXX" as context.
2026-04-29 13:54:08 +02:00
Jiayuan Zhang
174b8c62a6 fix(views): remove redundant issue identifier from breadcrumb navigation (#1872)
The issue detail page breadcrumb showed both the issue identifier and
title (e.g. "MUL-1567 Title"), making the ID appear twice. Remove the
standalone identifier span so only the title is displayed.
2026-04-29 13:50:43 +02:00
Jiayuan Zhang
768d3f8b0c feat(ui): make New Issue button open Quick Capture instead of manual form (#1862)
* feat(ui): make New Issue button open Quick Capture instead of manual form

The sidebar "New Issue" button and the search command's "New Issue" action
now open the agent-based Quick Capture dialog directly, matching the
platform's agent-first workflow.

Contextual issue creation (board columns, list view status groups, sub-issues)
still opens the manual form since those pass pre-filled data.

Closes MUL-1558

* test(search): update search-command test to expect quick-create-issue

Aligns the test assertion with the behavior change in the previous
commit where "New Issue" now opens Quick Capture.
2026-04-29 13:48:50 +02:00
Jiayuan Zhang
7dfa72465c feat(quick-create): add file upload button to Quick Capture dialog (#1866)
The agent-mode Quick Capture dialog already supported image paste and
drag-drop through the ContentEditor, but lacked a visible file
attachment button. This made the feature undiscoverable.

Add a FileUploadButton (paperclip icon) to the footer, matching the
pattern already used by the manual create panel and comment input.
2026-04-29 13:48:44 +02:00
Jiayuan Zhang
0b969483a6 fix(quick-create): block submit while image uploads are in progress (#1864)
Without this guard, submitting during an active upload causes
stripBlobUrls to silently remove the in-flight blob image from the
markdown, so the agent never sees the pasted screenshot. Now the Create
button disables and shows "Uploading…" until all file uploads resolve.
2026-04-29 13:48:35 +02:00
Bohan Jiang
e024ab1232 fix(desktop): show git-described version in dev instead of stale 0.1.0 (#1867)
Packaged builds are unaffected: scripts/package.mjs already injects the
git tag into electron-builder's extraMetadata.version, so the .app users
download from GitHub Release reports the right version through
app.getVersion() and the auto-updater's latest.yml comparison works
correctly.

Dev mode (`pnpm dev:desktop`) didn't go through that path though, so
app.getVersion() returned the static "0.1.0" from package.json — the
new Settings → Updates panel surfaced this and made it look like the
dev build was ancient. Add a tiny getAppVersion() helper that falls
back to `git describe --tags --always --dirty` only when !app.isPackaged,
and use it for the app-info IPC. No change to packaged behavior; if git
is unavailable for any reason, we silently fall back to app.getVersion().
2026-04-29 19:18:41 +08:00
29 changed files with 790 additions and 108 deletions

View File

@@ -0,0 +1,33 @@
import { app } from "electron";
import { execSync } from "node:child_process";
/**
* Resolve the running app version. In packaged builds this is the value
* `electron-builder` baked into package.json via `extraMetadata.version`
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
* `app.getVersion()` matches the GitHub Release tag exactly.
*
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
* the Settings → Updates panel and any other UI surfacing the version
* would mislead developers into thinking they're running ancient builds.
* Fall back to `git describe --tags --always --dirty` (same source the
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
* unavailable for whatever reason, we just return the package.json value.
*/
export function getAppVersion(): string {
if (app.isPackaged) {
return app.getVersion();
}
try {
const raw = execSync("git describe --tags --always --dirty", {
cwd: app.getAppPath(),
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (!raw) return app.getVersion();
return raw.replace(/^v/, "");
} catch {
return app.getVersion();
}
}

View File

@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { getAppVersion } from "./app-version";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
@@ -203,7 +204,7 @@ if (!gotTheLock) {
ipcMain.on("app:get-info", (event) => {
const p = process.platform;
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
event.returnValue = { version: app.getVersion(), os };
event.returnValue = { version: getAppVersion(), os };
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen

View File

@@ -110,18 +110,19 @@ function AppContent() {
: undefined;
useDaemonIPCBridge(activeWsId);
// Onboarding and zero-workspace both resolve to an overlay, but
// onboarding wins: a user who hasn't completed it gets the onboarding
// overlay regardless of how many workspaces already exist.
// Workspace presence wins over onboarding state: a user invited into an
// existing workspace must enter that workspace, not be trapped in the
// onboarding overlay just because their personal `onboarded_at` is null.
// Onboarding is only the right destination when the account has zero
// workspaces AND has never onboarded.
useEffect(() => {
if (!user || !workspaceListFetched) return;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return;
if (wsCount > 0) return;
if (!hasOnboarded) {
open({ type: "onboarding" });
return;
}
if (wsCount === 0) {
} else {
open({ type: "new-workspace" });
}
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);

View File

@@ -1 +1,38 @@
import "@testing-library/jest-dom/vitest";
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
key: (index: number) => Array.from(values.keys())[index] ?? null,
removeItem: (key: string) => {
values.delete(key);
},
setItem: (key: string, value: string) => {
values.set(key, value);
},
};
}
const localStorageIsUsable =
typeof globalThis.localStorage?.getItem === "function" &&
typeof globalThis.localStorage?.setItem === "function" &&
typeof globalThis.localStorage?.removeItem === "function" &&
typeof globalThis.localStorage?.clear === "function";
if (!localStorageIsUsable) {
const storage = createMemoryStorage();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(window, "localStorage", {
configurable: true,
value: storage,
});
}

View File

@@ -72,10 +72,6 @@ function LoginPageContent() {
});
return;
}
if (!hasOnboarded) {
router.replace(paths.onboarding());
return;
}
if (nextUrl) {
router.replace(nextUrl);
return;
@@ -89,10 +85,6 @@ function LoginPageContent() {
// was captured before login completed and would be stale here.
const currentUser = useAuthStore.getState().user;
const onboarded = currentUser?.onboarded_at != null;
if (!onboarded) {
router.push(paths.onboarding());
return;
}
if (nextUrl) {
router.push(nextUrl);
return;

View File

@@ -32,20 +32,25 @@ export default function OnboardingPage() {
const hasOnboarded = useHasOnboarded();
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user && hasOnboarded,
enabled: !!user,
});
const hasWorkspaces = workspaces.length > 0;
useEffect(() => {
if (isLoading || !user) {
if (!isLoading && !user) router.replace(paths.login());
return;
}
if (hasOnboarded && workspacesFetched) {
if (!workspacesFetched) return;
// Bounce out if onboarding doesn't apply: either already onboarded, or
// the user already has a workspace (e.g. arrived via invitation) — we
// never trap an in-workspace user on the onboarding screen.
if (hasOnboarded || hasWorkspaces) {
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, hasWorkspaces, router]);
if (isLoading || !user || hasOnboarded) return null;
if (isLoading || !user || hasOnboarded || hasWorkspaces) return null;
// Layout: page owns its own scroll (root layout sets `body {
// overflow: hidden }` for the app-shell convention). OnboardingFlow

View File

@@ -61,28 +61,54 @@ import CallbackPage from "./page";
describe("CallbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
// Snapshot keys before deleting — forEach + delete skips entries because
// the iteration index advances while the underlying list shrinks.
Array.from(mockSearchParams.keys()).forEach((k) =>
mockSearchParams.delete(k),
);
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(makeUser());
mockListWorkspaces.mockResolvedValue([]);
});
it("unonboarded user lands on /onboarding regardless of next=", async () => {
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
});
it("unonboarded user with no next= also lands on /onboarding", async () => {
it("unonboarded user with no next= and zero workspaces lands on /onboarding", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
});
it("unonboarded user with existing workspace lands in that workspace, not /onboarding", async () => {
mockListWorkspaces.mockResolvedValue([
{
id: "ws-1",
name: "Acme",
slug: "acme",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "ACME",
created_at: "",
updated_at: "",
},
]);
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
});
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
});
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
mockLoginWithGoogle.mockResolvedValue(
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),

View File

@@ -66,10 +66,10 @@ function CallbackContent() {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
const onboarded = loggedInUser.onboarded_at != null;
if (!onboarded) {
router.push(paths.onboarding());
return;
}
// Workspace presence beats onboarding state: an invitee with zero
// `onboarded_at` but a real workspace must land in that workspace,
// not in the new-workspace wizard. A `next=` (e.g. /invite/<id>)
// always wins so invite acceptance flows survive auth round-trips.
router.push(
nextUrl || resolvePostAuthDestination(wsList, onboarded),
);

View File

@@ -15,6 +15,8 @@ import { defaultStorage } from "../../platform/storage";
interface QuickCreateState {
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
keepOpen: boolean;
setKeepOpen: (v: boolean) => void;
}
export const useQuickCreateStore = create<QuickCreateState>()(
@@ -22,6 +24,8 @@ export const useQuickCreateStore = create<QuickCreateState>()(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
keepOpen: false,
setKeepOpen: (v) => set({ keepOpen: v }),
}),
{
name: "multica_quick_create",

View File

@@ -19,24 +19,24 @@ function makeWs(slug: string): Workspace {
}
describe("resolvePostAuthDestination", () => {
it("not onboarded → /onboarding regardless of workspaces", () => {
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
paths.onboarding(),
);
expect(
resolvePostAuthDestination([makeWs("acme"), makeWs("beta")], false),
).toBe(paths.onboarding());
});
it("onboarded + has workspace → /<first.slug>/issues", () => {
it("has workspace → /<first.slug>/issues regardless of onboarded state", () => {
const ws = [makeWs("acme"), makeWs("beta")];
expect(resolvePostAuthDestination(ws, true)).toBe(
paths.workspace("acme").issues(),
);
expect(resolvePostAuthDestination(ws, false)).toBe(
paths.workspace("acme").issues(),
);
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
paths.workspace("acme").issues(),
);
});
it("onboarded + zero workspaces → /workspaces/new", () => {
it("zero workspaces + !onboarded → /onboarding", () => {
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
});
it("zero workspaces + onboarded → /workspaces/new", () => {
expect(resolvePostAuthDestination([], true)).toBe(paths.newWorkspace());
});
});

View File

@@ -4,19 +4,23 @@ import { paths } from "./paths";
/**
* Priority:
* !hasOnboarded → /onboarding
* hasOnboarded && has workspace → /<first.slug>/issues
* hasOnboarded && zero workspaces → /workspaces/new
* has workspace → /<first.slug>/issues
* zero workspaces && !hasOnboarded → /onboarding
* zero workspaces && hasOnboarded → /workspaces/new
*
* Workspace presence wins over onboarding state: a user invited into an
* existing workspace must NOT be bounced into the new-workspace wizard
* just because their personal `onboarded_at` is still null.
*/
export function resolvePostAuthDestination(
workspaces: Workspace[],
hasOnboarded: boolean,
): string {
if (!hasOnboarded) {
return paths.onboarding();
}
const first = workspaces[0];
return first ? paths.workspace(first.slug).issues() : paths.newWorkspace();
if (first) {
return paths.workspace(first.slug).issues();
}
return hasOnboarded ? paths.newWorkspace() : paths.onboarding();
}
/**

View File

@@ -41,13 +41,12 @@ export function ModelPicker({
);
const supported = modelsQuery.data?.supported ?? true;
// Memoise the model list so every downstream useMemo gets a stable
// reference `?? []` would mint a fresh array on every render and
// invalidate filters / defaultModel needlessly.
// reference; `?? []` would mint a fresh array on every render and
// invalidate filters needlessly.
const models = useMemo(
() => modelsQuery.data?.models ?? [],
[modelsQuery.data],
);
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
const filtered = useMemo(() => {
const s = search.trim().toLowerCase();
@@ -78,9 +77,7 @@ export function ModelPicker({
);
}
const triggerLabel =
value ||
(defaultModel ? `Default — ${defaultModel.label}` : "Default");
const triggerLabel = value || "Default";
const triggerTitle = `Model · ${triggerLabel}`;
return (

View File

@@ -42,7 +42,6 @@ export function ModelDropdown({
const supported = modelsQuery.data?.supported ?? true;
const models = modelsQuery.data?.models ?? [];
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
const grouped = useMemo(() => groupByProvider(models), [models]);
// When the selected runtime reports it doesn't support per-agent
@@ -86,9 +85,7 @@ export function ModelDropdown({
(disabled
? "Select a runtime first"
: runtimeOnline
? defaultModel
? `Default — ${defaultModel.label}`
: "Default (provider)"
? "Default (provider)"
: "Runtime offline — enter manually");
if (!supported && !modelsQuery.isLoading) {

View File

@@ -4,6 +4,7 @@ import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { useActorName } from "@multica/core/workspace/hooks";
import { StatusIcon, PriorityIcon } from "../../issues/components";
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
import { getQuickCreateFailureDetail } from "./inbox-display";
const typeLabels: Record<InboxItemType, string> = {
issue_assigned: "Assigned",
@@ -20,8 +21,8 @@ const typeLabels: Record<InboxItemType, string> = {
agent_blocked: "Agent blocked",
agent_completed: "Agent completed",
reaction_added: "Reacted",
quick_create_done: "Quick create done",
quick_create_failed: "Quick create failed",
quick_create_done: "Created with agent",
quick_create_failed: "Create with agent failed",
};
export { typeLabels };
@@ -88,6 +89,16 @@ export function InboxDetailLabel({ item }: { item: InboxItem }) {
if (emoji) return <span>Reacted {emoji} to your comment</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "quick_create_done": {
const identifier = details.identifier;
if (identifier) return <span>Created with agent: {identifier}</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "quick_create_failed": {
const detail = getQuickCreateFailureDetail(item);
if (detail) return <span>Failed: {detail}</span>;
return <span>{typeLabels[item.type]}</span>;
}
default:
return <span>{typeLabels[item.type] ?? item.type}</span>;
}

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import type { InboxItem } from "@multica/core/types";
import {
getInboxDisplayTitle,
getQuickCreateFailureDetail,
stripQuickCreatePrefix,
} from "./inbox-display";
function item(overrides: Partial<InboxItem>): InboxItem {
return {
id: "inbox-1",
workspace_id: "workspace-1",
recipient_type: "member",
recipient_id: "member-1",
actor_type: "agent",
actor_id: "agent-1",
type: "new_comment",
severity: "info",
issue_id: "issue-1",
title: "Issue title",
body: null,
issue_status: null,
read: false,
archived: false,
created_at: "2026-04-29T12:00:00Z",
details: null,
...overrides,
};
}
describe("inbox display helpers", () => {
it("removes legacy quick-create created prefixes from list titles", () => {
expect(
stripQuickCreatePrefix(
"Created MUL-1583: Fix agent list column widths",
"MUL-1583",
),
).toBe("Fix agent list column widths");
});
it("cleans quick-create success titles before rendering the inbox row", () => {
const quickCreateItem = item({
type: "quick_create_done",
title: "Created MUL-1583: Fix agent list column widths",
details: { identifier: "MUL-1583" },
});
expect(getInboxDisplayTitle(quickCreateItem)).toBe(
"Fix agent list column widths",
);
});
it("uses the original prompt as the failed quick-create row title", () => {
const failedItem = item({
type: "quick_create_failed",
title: "Quick create failed",
body: "agent finished without creating an issue",
issue_id: null,
details: {
original_prompt: "Optimize QuickCapture UI\nand attached screenshot",
},
});
expect(getInboxDisplayTitle(failedItem)).toBe(
"Optimize QuickCapture UI and attached screenshot",
);
});
it("uses the redacted failure detail for failed quick-create subtitles", () => {
const failedItem = item({
type: "quick_create_failed",
body: "fallback body",
details: { error: "CLI failed\nwith exit status 1" },
});
expect(getQuickCreateFailureDetail(failedItem)).toBe(
"CLI failed with exit status 1",
);
});
});

View File

@@ -0,0 +1,49 @@
import type { InboxItem } from "@multica/core/types";
function singleLine(value: string | null | undefined): string {
return (value ?? "").replace(/\s+/g, " ").trim();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function stripQuickCreatePrefix(title: string, identifier?: string): string {
const normalized = singleLine(title);
if (!normalized) return "";
if (identifier) {
const exactPrefix = new RegExp(
`^Created\\s+${escapeRegExp(identifier)}:\\s*`,
"i",
);
const withoutExactPrefix = normalized.replace(exactPrefix, "");
if (withoutExactPrefix !== normalized) return withoutExactPrefix.trim();
}
return normalized.replace(/^Created\s+[A-Z][A-Z0-9]*-\d+:\s*/i, "").trim();
}
export function getInboxDisplayTitle(item: InboxItem): string {
const details = item.details ?? {};
if (item.type === "quick_create_done") {
const cleanedTitle = stripQuickCreatePrefix(item.title, details.identifier);
if (cleanedTitle) return cleanedTitle;
const prompt = singleLine(details.original_prompt);
if (prompt) return prompt;
}
if (item.type === "quick_create_failed") {
const prompt = singleLine(details.original_prompt);
if (prompt) return prompt;
}
return item.title;
}
export function getQuickCreateFailureDetail(item: InboxItem): string {
const details = item.details ?? {};
return singleLine(details.error) || singleLine(item.body);
}

View File

@@ -5,6 +5,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
import { Archive } from "lucide-react";
import type { InboxItem } from "@multica/core/types";
import { InboxDetailLabel } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
@@ -30,6 +31,8 @@ export function InboxListItem({
onClick: () => void;
onArchive: () => void;
}) {
const displayTitle = getInboxDisplayTitle(item);
return (
<button
onClick={onClick}
@@ -52,7 +55,7 @@ export function InboxListItem({
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{item.title}
{displayTitle}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">

View File

@@ -51,6 +51,7 @@ import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { PageHeader } from "../../layout/page-header";
import { InboxListItem, timeAgo } from "./inbox-list-item";
import { typeLabels } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
export function InboxPage() {
const { searchParams, replace } = useNavigation();
@@ -260,7 +261,7 @@ export function InboxPage() {
/>
) : selected ? (
<div className="p-6">
<h2 className="text-lg font-semibold">{selected.title}</h2>
<h2 className="text-lg font-semibold">{getInboxDisplayTitle(selected)}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
</p>

View File

@@ -399,14 +399,6 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByDisplayValue("Add JWT auth to the backend")).toBeInTheDocument();
});
it("renders issue identifier in the breadcrumb", async () => {
renderIssueDetail();
await waitFor(() => {
expect(screen.getByText("TES-1")).toBeInTheDocument();
});
});
it("renders workspace name as breadcrumb link", async () => {
renderIssueDetail();

View File

@@ -506,9 +506,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
<span className="shrink-0 text-muted-foreground">
{issue.identifier}
</span>
<span className="truncate font-medium text-foreground">
{issue.title}
</span>

View File

@@ -560,7 +560,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenuItem>
<SidebarMenuButton
className="text-muted-foreground"
onClick={() => useModalStore.getState().open("create-issue")}
onClick={() => useModalStore.getState().open("quick-create-issue")}
>
<span className="relative">
<SquarePen />

View File

@@ -21,8 +21,14 @@ import { useNavigation } from "../navigation";
* - Not logged in → /login
* - Logged in but workspace list not yet loaded → wait (don't bounce prematurely)
* - Logged in but URL slug doesn't resolve to any workspace →
* `resolvePostAuthDestination(list, hasOnboarded)` — onboarding for
* first-timers, /workspaces/new for returning users who deleted out.
* `resolvePostAuthDestination(list, hasOnboarded)` — first workspace if any,
* onboarding for first-timers, /workspaces/new for returning users who
* deleted out.
*
* Onboarding is NOT a separate gate: a user invited into a workspace can have
* `onboarded_at == null` yet legitimately belong inside a workspace, and must
* not be bounced to the new-workspace wizard. The onboarding redirect only
* fires from the resolver when the user has zero workspaces.
*
* We read the workspace list query state directly (rather than relying on
* useCurrentWorkspace's null return) so we can distinguish "list loading"
@@ -47,10 +53,6 @@ export function useDashboardGuard() {
return;
}
if (!workspaceListFetched) return;
if (!hasOnboarded) {
replace(paths.onboarding());
return;
}
if (!workspace) {
replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeftRight, ChevronRight, X as XIcon } from "lucide-react";
import { ArrowLeftRight, Check, ChevronRight, X as XIcon } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { DialogTitle } from "@multica/ui/components/ui/dialog";
@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { api, ApiError } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { useCurrentWorkspace } from "@multica/core/paths";
@@ -37,6 +38,7 @@ import {
useFileDropZone,
FileDropOverlay,
} from "../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
// only the inner content; the surrounding `<Dialog>` AND `<DialogContent>`
@@ -78,6 +80,8 @@ export function AgentCreatePanel({
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
const keepOpen = useQuickCreateStore((s) => s.keepOpen);
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
const setLastMode = useCreateModeStore((s) => s.setLastMode);
const [agentId, setAgentId] = useState<string | undefined>(() => {
@@ -130,12 +134,14 @@ export function AgentCreatePanel({
const editorRef = useRef<ContentEditorRef>(null);
const [hasContent, setHasContent] = useState(initialPrompt.trim().length > 0);
const [submitting, setSubmitting] = useState(false);
const [justSent, setJustSent] = useState(false);
const [sentCount, setSentCount] = useState(0);
const [error, setError] = useState<string | null>(null);
// Image paste/drop support: route uploads through the same helper Advanced
// uses, so users can paste screenshots straight into the prompt and the
// agent receives them as embedded markdown image URLs in the prompt.
const { uploadWithToast } = useFileUpload(api);
const { uploadWithToast, uploading } = useFileUpload(api);
const handleUploadFile = useCallback(
(file: File) => uploadWithToast(file),
[uploadWithToast],
@@ -154,7 +160,7 @@ export function AgentCreatePanel({
const submit = async () => {
const md = editorRef.current?.getMarkdown()?.trim() ?? "";
if (!md || !agentId || submitting || versionBlocked) return;
if (!md || !agentId || submitting || versionBlocked || uploading) return;
setSubmitting(true);
setError(null);
try {
@@ -164,7 +170,18 @@ export function AgentCreatePanel({
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
duration: 4000,
});
onClose();
if (keepOpen) {
// Stay open for continuous creation — clear the editor so the
// user can immediately type the next prompt.
editorRef.current?.clearContent();
setHasContent(false);
setSentCount((c) => c + 1);
setJustSent(true);
setTimeout(() => setJustSent(false), 1500);
requestAnimationFrame(() => editorRef.current?.focus());
} else {
onClose();
}
} catch (e) {
// Server returns 422 with { code, ... } for the structured rejection
// paths the modal cares about. Surface the reason in-modal so the
@@ -334,7 +351,18 @@ export function AgentCreatePanel({
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<span className="text-xs text-muted-foreground"> to submit</span>
<div className="flex items-center gap-1.5">
<FileUploadButton
size="sm"
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<span className="text-xs text-muted-foreground">
{keepOpen && sentCount > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">{sentCount} sent · </span>
)}
to submit
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
@@ -345,17 +373,28 @@ export function AgentCreatePanel({
<ArrowLeftRight className="size-3.5" />
Switch to manual
</button>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<Switch
size="sm"
checked={keepOpen}
onCheckedChange={setKeepOpen}
/>
Create another
</label>
<Button
size="sm"
onClick={submit}
disabled={!hasContent || !agentId || submitting || versionBlocked}
disabled={!hasContent || !agentId || submitting || versionBlocked || uploading}
title={
versionBlocked
? `Daemon CLI must be ≥ ${versionCheck.min}`
: undefined
}
className={justSent ? "!bg-emerald-600 !text-white" : undefined}
>
{submitting ? "Sending…" : "Create"}
{submitting ? "Sending…" : uploading ? "Uploading…" : justSent ? (
<span className="flex items-center gap-1"><Check className="size-3.5" />Sent</span>
) : "Create"}
</Button>
</div>
</div>

View File

@@ -0,0 +1,388 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Check,
ChevronRight,
Copy,
Loader2,
Server,
ShieldAlert,
Terminal,
Wrench,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeKeys } from "@multica/core/runtimes/queries";
import { useWSEvent } from "@multica/core/realtime";
import { paths, useWorkspaceSlug } from "@multica/core/paths";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Button } from "@multica/ui/components/ui/button";
import { useNavigation } from "../../navigation";
type Step = "instructions" | "waiting" | "success";
export function ConnectRemoteDialog({ onClose }: { onClose: () => void }) {
const [step, setStep] = useState<Step>("instructions");
const [copied, setCopied] = useState<string | null>(null);
const wsId = useWorkspaceId();
const slug = useWorkspaceSlug();
const qc = useQueryClient();
const navigation = useNavigation();
const newRuntimeIdRef = useRef<string | null>(null);
// Listen for a new runtime registration while the dialog is open
const handleDaemonRegister = useCallback(
(payload: unknown) => {
if (step === "waiting" || step === "instructions") {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
const p = payload as Record<string, unknown> | null;
if (p?.runtime_id && typeof p.runtime_id === "string") {
newRuntimeIdRef.current = p.runtime_id;
}
setStep("success");
}
},
[step, qc, wsId],
);
useWSEvent("daemon:register", handleDaemonRegister);
const copyToClipboard = useCallback(
(text: string, key: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
},
[],
);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(null), 2000);
return () => clearTimeout(t);
}, [copied]);
const handleGoToAgents = () => {
onClose();
if (slug) {
navigation.push(paths.workspace(slug).agents());
}
};
const handleGoToRuntime = () => {
onClose();
if (slug && newRuntimeIdRef.current) {
navigation.push(
paths.workspace(slug).runtimeDetail(newRuntimeIdRef.current),
);
}
};
return (
<Dialog open onOpenChange={(v) => !v && onClose()}>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-xl">
{step === "instructions" && (
<InstructionsStep
copied={copied}
onCopy={copyToClipboard}
onNext={() => setStep("waiting")}
onClose={onClose}
/>
)}
{step === "waiting" && (
<WaitingStep onBack={() => setStep("instructions")} />
)}
{step === "success" && (
<SuccessStep
onGoToAgents={handleGoToAgents}
onGoToRuntime={
newRuntimeIdRef.current ? handleGoToRuntime : undefined
}
/>
)}
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Step 1: Installation instructions
// ---------------------------------------------------------------------------
const INSTALL_CMD = "curl -fsSL https://multica.ai/install.sh | sh";
const CONFIGURE_CMD = `multica config set server_url https://api.multica.ai
multica config set app_url https://multica.ai`;
const LOGIN_CMD = "multica login --token <YOUR_TOKEN>";
const START_CMD = `multica daemon start --device-name "my-ec2-instance"
multica daemon status`;
function CodeBlock({
code,
copyKey,
copied,
onCopy,
}: {
code: string;
copyKey: string;
copied: string | null;
onCopy: (text: string, key: string) => void;
}) {
const isCopied = copied === copyKey;
return (
<div className="relative rounded-md border bg-muted/50">
<pre className="overflow-x-auto p-2.5 pr-10 font-mono text-xs leading-relaxed text-foreground">
{code}
</pre>
<button
type="button"
onClick={() => onCopy(code, copyKey)}
className="absolute top-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded border bg-background text-muted-foreground transition-colors hover:text-foreground"
>
{isCopied ? (
<Check className="h-3 w-3 text-success" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
);
}
function InstructionsStep({
copied,
onCopy,
onNext,
onClose,
}: {
copied: string | null;
onCopy: (text: string, key: string) => void;
onNext: () => void;
onClose: () => void;
}) {
return (
<>
<DialogHeader>
<DialogTitle>Connect a remote machine</DialogTitle>
<DialogDescription>
Run these commands on your remote machine (e.g. AWS EC2) to install the
Multica CLI and register it as a runtime.
</DialogDescription>
</DialogHeader>
<div className="-mx-4 min-h-0 flex-1 overflow-y-auto px-4">
<div className="space-y-3">
{/* Step 1: Install */}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Terminal className="h-3.5 w-3.5" />
1. Install the CLI
</div>
<CodeBlock
code={INSTALL_CMD}
copyKey="install"
copied={copied}
onCopy={onCopy}
/>
</div>
{/* Step 2: Configure */}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Server className="h-3.5 w-3.5" />
2. Configure
</div>
<CodeBlock
code={CONFIGURE_CMD}
copyKey="config"
copied={copied}
onCopy={onCopy}
/>
</div>
{/* Step 3: Login */}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
3. Login with a personal access token
</div>
<CodeBlock
code={LOGIN_CMD}
copyKey="login"
copied={copied}
onCopy={onCopy}
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Create one in{" "}
<span className="font-medium text-foreground">
Settings Tokens
</span>
.
</p>
</div>
{/* Step 4: Start daemon */}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
4. Start the daemon
</div>
<CodeBlock
code={START_CMD}
copyKey="start"
copied={copied}
onCopy={onCopy}
/>
</div>
{/* Security tips */}
<div className="rounded-md border border-warning/30 bg-warning/5 p-2.5">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
<div className="text-[11px] leading-relaxed text-muted-foreground">
<span className="font-medium text-foreground">Security: </span>
Use an EC2 IAM role or least-privilege credentials. Never put
root keys into agent{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
custom_env
</code>
. The daemon uses outbound connections only no inbound ports
needed.
</div>
</div>
</div>
{/* Troubleshooting */}
<details className="group pb-1">
<summary className="flex cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground">
<Wrench className="h-3.5 w-3.5" />
Troubleshooting
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
</summary>
<ul className="mt-1.5 list-disc space-y-0.5 pl-8 text-[11px] text-muted-foreground">
<li>
Check status:{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
multica daemon status
</code>
</li>
<li>
View logs:{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
multica daemon logs -f
</code>
</li>
<li>
Verify provider:{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
claude --version
</code>
</li>
<li>
Desktop auto-scans only your local machine. Remote machines must
run{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
multica daemon
</code>{" "}
separately.
</li>
</ul>
</details>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={onNext}>
I&apos;ve started the daemon
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</DialogFooter>
</>
);
}
// ---------------------------------------------------------------------------
// Step 2: Waiting for registration
// ---------------------------------------------------------------------------
function WaitingStep({ onBack }: { onBack: () => void }) {
return (
<>
<DialogHeader>
<DialogTitle>Waiting for runtime</DialogTitle>
<DialogDescription>
Listening for your remote daemon to register. This page updates
automatically no need to refresh.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Run{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
multica daemon status
</code>{" "}
on the remote machine to verify it&apos;s running.
</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onBack}>
Back
</Button>
</DialogFooter>
</>
);
}
// ---------------------------------------------------------------------------
// Step 3: Success
// ---------------------------------------------------------------------------
function SuccessStep({
onGoToAgents,
onGoToRuntime,
}: {
onGoToAgents: () => void;
onGoToRuntime?: () => void;
}) {
return (
<>
<DialogHeader>
<DialogTitle>Runtime connected!</DialogTitle>
<DialogDescription>
Your remote machine has registered as a runtime. You can now create an
agent that dispatches tasks to it.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-success/10">
<Check className="h-6 w-6 text-success" />
</div>
</div>
<DialogFooter>
{onGoToRuntime && (
<Button variant="ghost" onClick={onGoToRuntime}>
View runtime
</Button>
)}
<Button onClick={onGoToAgents}>
Create an agent
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</DialogFooter>
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Search, Server } from "lucide-react";
import { Plus, Search, Server } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -18,6 +18,7 @@ import {
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { PageHeader } from "../../layout/page-header";
import { ConnectRemoteDialog } from "./connect-remote-dialog";
import { RuntimeList } from "./runtime-list";
type RuntimeFilter = "mine" | "all";
@@ -92,6 +93,7 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
const [scope, setScope] = useState<RuntimeFilter>("mine");
const [healthFilter, setHealthFilter] = useState<HealthFilter>("all");
const [search, setSearch] = useState("");
const [showConnectDialog, setShowConnectDialog] = useState(false);
// One unified cache per workspace: scope (Mine/All) is a view filter, not
// a fetch dimension. Splitting on owner used to give us two TanStack cache
@@ -154,14 +156,17 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
return (
<div className="flex flex-1 min-h-0 flex-col">
<PageHeaderBar totalCount={totalCount} />
<PageHeaderBar
totalCount={totalCount}
onConnectRemote={() => setShowConnectDialog(true)}
/>
<div className="flex flex-1 min-h-0 flex-col gap-4 p-6">
{topSlot}
{showEmpty ? (
<div className="flex flex-1 items-center justify-center">
<EmptyState />
<EmptyState onConnectRemote={() => setShowConnectDialog(true)} />
</div>
) : (
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
@@ -189,6 +194,10 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
</div>
)}
</div>
{showConnectDialog && (
<ConnectRemoteDialog onClose={() => setShowConnectDialog(false)} />
)}
</div>
);
}
@@ -198,9 +207,15 @@ export function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {})
// Page-level actions (Search, scope, filter) live in the card below.
// ---------------------------------------------------------------------------
function PageHeaderBar({ totalCount }: { totalCount: number }) {
function PageHeaderBar({
totalCount,
onConnectRemote,
}: {
totalCount: number;
onConnectRemote: () => void;
}) {
return (
<PageHeader className="px-5">
<PageHeader className="justify-between px-5">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<h1 className="text-sm font-medium">Runtimes</h1>
@@ -209,9 +224,6 @@ function PageHeaderBar({ totalCount }: { totalCount: number }) {
{totalCount}
</span>
)}
{/* Tagline sits right next to the title — same flex group, single
sentence + docs link. Hidden below md so it never collides with
the title on narrow screens. */}
<p className="ml-2 hidden text-xs text-muted-foreground md:block">
Machines and cloud workers running CLI sessions for your agents.{" "}
<a
@@ -224,6 +236,10 @@ function PageHeaderBar({ totalCount }: { totalCount: number }) {
</a>
</p>
</div>
<Button type="button" size="sm" onClick={onConnectRemote}>
<Plus className="h-3 w-3" />
Connect remote machine
</Button>
</PageHeader>
);
}
@@ -413,7 +429,7 @@ function HealthChip({
// workspace. Different from "filter matches nothing" (NoMatchesState).
// ---------------------------------------------------------------------------
function EmptyState() {
function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
@@ -421,12 +437,18 @@ function EmptyState() {
</div>
<h2 className="mt-4 text-base font-semibold">No runtimes yet</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Runtimes register automatically when a daemon connects. Run{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
multica daemon start
</code>{" "}
on your machine, or invite a teammate whose daemon is already running.
Desktop auto-scans your local machine. For AWS EC2 or other remote
machines, connect them using the setup wizard.
</p>
<Button
type="button"
size="sm"
onClick={onConnectRemote}
className="mt-5"
>
<Plus className="h-3 w-3" />
Connect remote machine
</Button>
</div>
);
}

View File

@@ -261,7 +261,7 @@ describe("SearchCommand", () => {
);
await user.click(newIssue);
expect(mockOpenModal).toHaveBeenCalledWith("create-issue");
expect(mockOpenModal).toHaveBeenCalledWith("quick-create-issue");
expect(useSearchStore.getState().open).toBe(false);
});

View File

@@ -202,7 +202,7 @@ export function SearchCommand() {
icon: Plus,
keywords: ["new", "issue", "create", "add"],
onSelect: () => {
useModalStore.getState().open("create-issue");
useModalStore.getState().open("quick-create-issue");
setOpen(false);
},
},

View File

@@ -1424,10 +1424,11 @@ func (s *TaskService) notifyQuickCreateCompleted(ctx context.Context, task db.Ag
prefix := s.getIssuePrefix(workspaceID)
identifier := fmt.Sprintf("%s-%d", prefix, issue.Number)
details, _ := json.Marshal(map[string]any{
"task_id": util.UUIDToString(task.ID),
"agent_id": util.UUIDToString(task.AgentID),
"issue_id": util.UUIDToString(issue.ID),
"identifier": identifier,
"task_id": util.UUIDToString(task.ID),
"agent_id": util.UUIDToString(task.AgentID),
"issue_id": util.UUIDToString(issue.ID),
"identifier": identifier,
"original_prompt": qc.Prompt,
})
item, err := s.Queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
WorkspaceID: workspaceID,
@@ -1436,7 +1437,7 @@ func (s *TaskService) notifyQuickCreateCompleted(ctx context.Context, task db.Ag
Type: "quick_create_done",
Severity: "info",
IssueID: issue.ID,
Title: fmt.Sprintf("Created %s: %s", identifier, issue.Title),
Title: issue.Title,
Body: pgtype.Text{},
ActorType: pgtype.Text{String: "agent", Valid: true},
ActorID: task.AgentID,

View File

@@ -18,7 +18,7 @@
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "app/**", "**/*.ts", "**/*.tsx", "**/*.css"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
"outputs": [".next/**", "!.next/cache/**", "dist/**", "out/**"]
},
"dev": {
"cache": false,