Compare commits

...

7 Commits

Author SHA1 Message Date
Jiayuan
456c9b6183 fix(desktop): prevent Cmd+R / Ctrl+R / F5 from reloading the page
In a desktop app an accidental page reload destroys in-memory state
(tabs, drafts, WS connections) with no URL bar to navigate back.

Add a before-input-event listener on the main BrowserWindow that
intercepts Cmd+R / Ctrl+R (with or without Shift) and F5, calling
preventDefault() to block the reload. DevTools refresh still works.
2026-04-29 18:01:57 +02:00
Jiayuan Zhang
f508190065 feat(modals): persist drafts for create-project and feedback modals (#1894)
Add Zustand persisted draft stores for the create-project and feedback
modals, following the same pattern as the existing issue draft store.
Drafts are saved to localStorage on every field change and restored
when the modal reopens, preventing accidental data loss on close.
Draft is cleared on successful submit.
2026-04-29 17:58:19 +02:00
Jiayuan Zhang
d5611d550a fix(inbox): auto-archive inbox item when marking done from issue detail (#1893)
When viewing an inbox notification's issue detail and clicking the "Mark
as done" toolbar button, the inbox item was not archived — only the issue
status changed. Add an onDone callback to IssueDetail so the inbox page
can archive the notification alongside the status update, matching the
behavior of the list-item Done button.

Closes MUL-1594
2026-04-29 17:57:00 +02:00
Jiayuan Zhang
28b29ec5ee feat(views): add remote machine / AWS EC2 connection wizard to Runtimes page (#1886)
* 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

* 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:35:45 +02:00
Jiayuan Zhang
b98c2a5a0f feat(inbox): add one-click Done button to inbox items (#1885)
* feat(inbox): add one-click Done button to inbox items

Add a hover-visible "Mark as done" button (CircleCheck icon) to each
inbox item that has an associated issue not yet in done/cancelled status.
Clicking it sets the issue status to "done" and archives the inbox item
in one action, replacing the previous multi-step flow of opening the
issue detail sidebar to change status.

* feat(issues): add Mark Done button to issue detail toolbar

Add a "Mark as done" button (CircleCheck icon) to the issue detail
header toolbar, positioned to the left of the Pin button. The button
is only visible when the issue status is not already done or cancelled.
Clicking it sets the issue status to "done" via the existing
handleUpdateField action.
2026-04-29 16:07:34 +02:00
Multica Eve
b9118ae9b8 Refine Quick Create agent modal (#1879)
* fix: refine quick create agent modal

* fix: align quick create toolbar feedback

* fix: sync create mode toolbar options

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 15:55:00 +02:00
Multica Eve
06880d6ba2 fix: make workspace table columns resizable (#1881)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Jiayuan Zhang <forrestchang7@gmail.com>
2026-04-29 15:23:12 +02:00
24 changed files with 961 additions and 87 deletions

View File

@@ -111,6 +111,22 @@ function createWindow(): void {
return { action: "deny" };
});
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
// reloading the page. In a desktop app an accidental reload destroys
// in-memory state (tabs, drafts, WS connections) with no URL bar to
// navigate back. DevTools refresh (via the DevTools UI) still works.
mainWindow.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return;
const cmdOrCtrl =
process.platform === "darwin" ? input.meta : input.control;
if (
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
input.key === "F5"
) {
_event.preventDefault();
}
});
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {

View File

@@ -0,0 +1,41 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
interface FeedbackDraft {
message: string;
}
const EMPTY_DRAFT: FeedbackDraft = {
message: "",
};
interface FeedbackDraftStore {
draft: FeedbackDraft;
setDraft: (patch: Partial<FeedbackDraft>) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useFeedbackDraftStore = create<FeedbackDraftStore>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
clearDraft: () =>
set({ draft: { ...EMPTY_DRAFT } }),
hasDraft: () => {
const { draft } = get();
return !!draft.message;
},
}),
{
name: "multica_feedback_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useFeedbackDraftStore.persist.rehydrate());

View File

@@ -1 +1,2 @@
export * from "./mutations";
export { useFeedbackDraftStore } from "./draft-store";

View File

@@ -0,0 +1,54 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { ProjectStatus, ProjectPriority } from "../types";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
interface ProjectDraft {
title: string;
description: string;
status: ProjectStatus;
priority: ProjectPriority;
leadType?: "member" | "agent";
leadId?: string;
icon?: string;
}
const EMPTY_DRAFT: ProjectDraft = {
title: "",
description: "",
status: "planned",
priority: "none",
leadType: undefined,
leadId: undefined,
icon: undefined,
};
interface ProjectDraftStore {
draft: ProjectDraft;
setDraft: (patch: Partial<ProjectDraft>) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useProjectDraftStore = create<ProjectDraftStore>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
clearDraft: () =>
set({ draft: { ...EMPTY_DRAFT } }),
hasDraft: () => {
const { draft } = get();
return !!(draft.title || draft.description);
},
}),
{
name: "multica_project_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useProjectDraftStore.persist.rehydrate());

View File

@@ -1,2 +1,3 @@
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
export { useProjectDraftStore } from "./draft-store";

View File

@@ -36,6 +36,8 @@ function FileUploadButton({
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
aria-label="Attach file"
title="Attach file"
className={cn(
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
btnSize,

View File

@@ -2,10 +2,11 @@
import {
flexRender,
type Header as TanstackHeader,
type Row,
type Table as TanstackTable,
} from "@tanstack/react-table";
import type * as React from "react";
import * as React from "react";
// We deliberately use the lower-level shadcn primitives (TableHeader /
// TableBody / TableRow / TableHead / TableCell) but NOT the wrapping
@@ -48,8 +49,8 @@ interface DataTableProps<TData> extends React.ComponentProps<"div"> {
// makes each column's width come from its first row's <th>
// inline width. column.size is authoritative for sized columns.
// - Columns flagged `meta.grow: true` skip their inline width, so
// fixed table-layout assigns them the leftover space (no spacer
// column needed).
// fixed table-layout assigns them the leftover space until the user
// resizes them. Once resized, the explicit width is applied.
// - The table's `min-width` is the sum of every column's TanStack
// size (`table.getTotalSize()`). That gives grow columns a real
// floor — fixed mode ignores cell-level min-width, but it does
@@ -64,6 +65,98 @@ export function DataTable<TData>({
className,
...props
}: DataTableProps<TData>) {
const [resizingColumnId, setResizingColumnId] = React.useState<string | null>(
null,
);
const columnSizing = table.getState().columnSizing;
const hasExplicitSize = React.useCallback(
(columnId: string) =>
Object.prototype.hasOwnProperty.call(columnSizing, columnId),
[columnSizing],
);
const setColumnWidth = React.useCallback(
(header: TanstackHeader<TData, unknown>, width: number) => {
const minSize = header.column.columnDef.minSize ?? 48;
const maxSize =
header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
const next = Math.min(maxSize, Math.max(minSize, Math.round(width)));
table.setColumnSizing((old) => ({
...old,
[header.column.id]: next,
}));
},
[table],
);
const beginColumnResize = React.useCallback(
(
header: TanstackHeader<TData, unknown>,
event: React.PointerEvent<HTMLDivElement>,
) => {
if (!header.column.getCanResize()) return;
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const headerCell = event.currentTarget.closest("th");
const startWidth =
headerCell?.getBoundingClientRect().width ?? header.column.getSize();
setResizingColumnId(header.column.id);
setColumnWidth(header, startWidth);
const originalCursor = document.body.style.cursor;
const originalUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (pointerEvent: PointerEvent) => {
setColumnWidth(header, startWidth + pointerEvent.clientX - startX);
};
const stopResize = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResize);
window.removeEventListener("pointercancel", stopResize);
document.body.style.cursor = originalCursor;
document.body.style.userSelect = originalUserSelect;
setResizingColumnId(null);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResize);
window.addEventListener("pointercancel", stopResize);
},
[setColumnWidth],
);
const handleResizeKeyDown = React.useCallback(
(
header: TanstackHeader<TData, unknown>,
event: React.KeyboardEvent<HTMLDivElement>,
) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
const headerCell = event.currentTarget.closest("th");
const currentWidth = hasExplicitSize(header.column.id)
? header.column.getSize()
: (headerCell?.getBoundingClientRect().width ??
header.column.getSize());
const direction = event.key === "ArrowRight" ? 1 : -1;
const step = event.shiftKey ? 20 : 8;
setColumnWidth(header, currentWidth + direction * step);
},
[hasExplicitSize, setColumnWidth],
);
return (
<div
className={cn("flex min-h-0 flex-1 flex-col", className)}
@@ -79,6 +172,13 @@ export function DataTable<TData>({
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
const isPinned = header.column.getIsPinned();
const columnHasExplicitSize = hasExplicitSize(
header.column.id,
);
const headerLabel =
typeof header.column.columnDef.header === "string"
? header.column.columnDef.header
: header.column.id;
return (
<TableHead
key={header.id}
@@ -98,10 +198,13 @@ export function DataTable<TData>({
// into the header strip rather than appearing as
// a white block under sticky scroll.
className={cn(
"h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
"relative h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
isPinned && "bg-muted/30 backdrop-blur",
)}
style={getCellStyle(header.column, { withBorder: true })}
style={getCellStyle(header.column, {
withBorder: true,
hasExplicitSize: columnHasExplicitSize,
})}
>
{header.isPlaceholder
? null
@@ -109,6 +212,33 @@ export function DataTable<TData>({
header.column.columnDef.header,
header.getContext(),
)}
{!header.isPlaceholder &&
header.column.getCanResize() && (
<div
role="separator"
aria-label={`Resize ${headerLabel} column`}
aria-orientation="vertical"
tabIndex={0}
className={cn(
"absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none outline-none",
"after:absolute after:top-1/2 after:right-0 after:h-4 after:w-px after:-translate-y-1/2 after:bg-border after:opacity-0 after:transition-opacity",
"hover:after:opacity-100 focus-visible:after:opacity-100",
resizingColumnId === header.column.id &&
"after:bg-primary after:opacity-100",
)}
onPointerDown={(event) =>
beginColumnResize(header, event)
}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
header.column.resetSize();
}}
onKeyDown={(event) =>
handleResizeKeyDown(header, event)
}
/>
)}
</TableHead>
);
})}
@@ -135,6 +265,9 @@ export function DataTable<TData>({
>
{row.getVisibleCells().map((cell) => {
const isPinned = cell.column.getIsPinned();
const columnHasExplicitSize = hasExplicitSize(
cell.column.id,
);
return (
<TableCell
key={cell.id}
@@ -151,7 +284,10 @@ export function DataTable<TData>({
isPinned &&
"bg-background group-hover:bg-muted/50",
)}
style={getCellStyle(cell.column, { withBorder: true })}
style={getCellStyle(cell.column, {
withBorder: true,
hasExplicitSize: columnHasExplicitSize,
})}
>
{flexRender(
cell.column.columnDef.cell,

View File

@@ -4,10 +4,9 @@ import type * as React from "react";
// Extend TanStack Table's ColumnMeta with a `grow` flag. TanStack merges
// a default `size: 150` into every columnDef, so "no explicit size" can't
// be detected by inspecting columnDef.size (it's always a number). Setting
// `meta: { grow: true }` is the official extension point DataTable then
// skips the inline width for these columns and lets fixed table-layout
// assign them the leftover space (Linear / GitHub-PR-list pattern: title
// column grows, others stay at their declared widths).
// `meta: { grow: true }` is the official extension point: DataTable skips
// the inline width for these columns until the user explicitly resizes them,
// then the resized width wins.
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
grow?: boolean;
@@ -25,10 +24,10 @@ declare module "@tanstack/react-table" {
// `group-hover:`.
export function getCellStyle<TData>(
column: Column<TData>,
options?: { withBorder?: boolean },
options?: { withBorder?: boolean; hasExplicitSize?: boolean },
): React.CSSProperties {
const grow = column.columnDef.meta?.grow;
const width = grow ? undefined : column.columnDef.size;
const width = grow && !options?.hasExplicitSize ? undefined : column.getSize();
const isPinned = column.getIsPinned();
if (!isPinned) {

View File

@@ -38,18 +38,17 @@ export interface AgentRow {
// column.size doubles as the cell's effective max-width: truncatable
// cells with `truncate` inside hit ellipsis at the column edge.
//
// The Agent column has `meta.grow: true` so DataTable skips its inline
// `width` — that lets fixed table-layout assign it the leftover space
// (= container width sum of other columns), so the table fills the
// viewport without an empty spacer column.
// The Agent and Runtime columns have `meta.grow: true` so DataTable skips
// their inline widths until the user resizes them. Fixed table-layout splits
// the leftover space between them, which keeps Agent from monopolising wide
// viewports while still giving both columns a real floor.
//
// The Agent column also keeps `size: 240` even though it isn't used for
// rendering. TanStack folds this into `table.getTotalSize()`, which
// DataTable applies as the table's `min-width`. That's how the agent
// column gets a real 240px floor: when the viewport drops below
// `sum + 240`, the table refuses to shrink further and the container
// scrolls instead. (Fixed table-layout ignores cell-level min-width
// per spec, so the floor has to live on the table itself.)
// The grow columns also keep their `size` values even though those widths
// are skipped for initial rendering. TanStack folds them into
// `table.getTotalSize()`, which DataTable applies as the table's `min-width`.
// That's how the grow columns get real floors: when the viewport drops below
// the summed column sizes, the table refuses to shrink further and the
// container scrolls instead.
const COL_WIDTHS = {
agent: 240,
status: 120,
@@ -102,6 +101,7 @@ export function createAgentColumns({
id: "runtime",
header: "Runtime",
size: COL_WIDTHS.runtime,
meta: { grow: true },
cell: ({ row }) => <RuntimeCell row={row.original} />,
},
{
@@ -126,6 +126,7 @@ export function createAgentColumns({
id: "actions",
header: () => null,
size: COL_WIDTHS.actions,
enableResizing: false,
cell: ({ row }) => (
<div
className="flex justify-end"

View File

@@ -334,6 +334,7 @@ export function AgentsPage() {
data: agentRows,
columns,
getCoreRowModel: getCoreRowModel(),
enableColumnResizing: true,
// Pin the kebab column right so it stays accessible during horizontal
// scroll — matches the pattern in Linear / Notion / GitHub.
initialState: { columnPinning: { right: ["actions"] } },

View File

@@ -2,7 +2,7 @@
import { StatusIcon } from "../../issues/components";
import { ActorAvatar } from "../../common/actor-avatar";
import { Archive } from "lucide-react";
import { Archive, CircleCheck } from "lucide-react";
import type { InboxItem } from "@multica/core/types";
import { InboxDetailLabel } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
@@ -25,11 +25,13 @@ export function InboxListItem({
isSelected,
onClick,
onArchive,
onDone,
}: {
item: InboxItem;
isSelected: boolean;
onClick: () => void;
onArchive: () => void;
onDone?: () => void;
}) {
const displayTitle = getInboxDisplayTitle(item);
@@ -59,6 +61,26 @@ export function InboxListItem({
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{onDone && (
<span
role="button"
tabIndex={-1}
title="Mark as done"
onClick={(e) => {
e.stopPropagation();
onDone();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onDone();
}
}}
className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-info group-hover:inline-flex"
>
<CircleCheck className="h-3.5 w-3.5" />
</span>
)}
<span
role="button"
tabIndex={-1}

View File

@@ -20,6 +20,7 @@ import {
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "@multica/core/inbox/mutations";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { IssueDetail } from "../../issues/components";
import { useNavigation } from "../../navigation";
import { toast } from "sonner";
@@ -117,6 +118,7 @@ export function InboxPage() {
const archiveAllMutation = useArchiveAllInbox();
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
const updateIssueMutation = useUpdateIssue();
// Auto-mark-read whenever a selected item is unread — covers both click-
// to-select and URL-param-select (e.g. OS notification click on desktop).
@@ -145,6 +147,18 @@ export function InboxPage() {
});
};
const handleDone = (item: InboxItem) => {
if (!item.issue_id) return;
setSelectedKey("");
updateIssueMutation.mutate(
{ id: item.issue_id, status: "done" },
{ onError: () => toast.error("Failed to mark as done") },
);
archiveMutation.mutate(item.id, {
onError: () => toast.error("Failed to archive"),
});
};
// Batch operations
const handleMarkAllRead = () => {
markAllReadMutation.mutate(undefined, {
@@ -235,6 +249,11 @@ export function InboxPage() {
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => handleSelect(item)}
onArchive={() => handleArchive(item.id)}
onDone={
item.issue_id && item.issue_status !== "done" && item.issue_status !== "cancelled"
? () => handleDone(item)
: undefined
}
/>
))}
</div>
@@ -258,6 +277,12 @@ export function InboxPage() {
// longer exists.
setSelectedKey("");
}}
onDone={() => {
setSelectedKey("");
archiveMutation.mutate(selected.id, {
onError: () => toast.error("Failed to archive"),
});
}}
/>
) : selected ? (
<div className="p-6">

View File

@@ -9,6 +9,7 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
CircleCheck,
MoreHorizontal,
PanelRight,
Pin,
@@ -138,6 +139,8 @@ function formatTokenCount(n: number): string {
interface IssueDetailProps {
issueId: string;
onDelete?: () => void;
/** Called after the issue is marked as done via the toolbar button. */
onDone?: () => void;
defaultSidebarOpen?: boolean;
layoutId?: string;
/** When set, the issue detail will auto-scroll to this comment and briefly highlight it. */
@@ -148,7 +151,7 @@ interface IssueDetailProps {
// IssueDetail
// ---------------------------------------------------------------------------
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
const id = issueId;
const router = useNavigation();
const user = useAuthStore((s) => s.user);
@@ -511,6 +514,23 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{issue.status !== "done" && issue.status !== "cancelled" && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => { handleUpdateField({ status: "done" }); onDone?.(); }}
>
<CircleCheck />
</Button>
}
/>
<TooltipContent side="bottom">Mark as done</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={

View File

@@ -9,6 +9,7 @@ const mockCreateIssue = vi.hoisted(() => vi.fn());
const mockSetDraft = vi.hoisted(() => vi.fn());
const mockClearDraft = vi.hoisted(() => vi.fn());
const mockSetLastAssignee = vi.hoisted(() => vi.fn());
const mockSetKeepOpen = vi.hoisted(() => vi.fn());
const mockToastCustom = vi.hoisted(() => vi.fn());
const mockToastDismiss = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
@@ -30,6 +31,11 @@ const mockDraftStore = {
setLastAssignee: mockSetLastAssignee,
};
const mockQuickCreateStore = {
keepOpen: false,
setKeepOpen: mockSetKeepOpen,
};
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: mockPush }),
}));
@@ -60,6 +66,11 @@ vi.mock("@multica/core/issues/stores/draft-store", () => ({
),
}));
vi.mock("@multica/core/issues/stores/quick-create-store", () => ({
useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) =>
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
}));
vi.mock("@multica/core/issues/mutations", () => ({
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
useUpdateIssue: () => ({ mutate: vi.fn() }),
@@ -79,6 +90,10 @@ vi.mock("../editor", () => {
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => {
valueRef.current = "";
setValue("");
},
uploadFile: vi.fn(),
}));
return (
@@ -178,6 +193,23 @@ vi.mock("@multica/ui/components/ui/button", () => ({
),
}));
vi.mock("@multica/ui/components/ui/switch", () => ({
Switch: ({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
}) => (
<input
aria-label="Create another"
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
/>
),
}));
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
@@ -210,6 +242,10 @@ function renderModal(element: React.ReactElement) {
describe("CreateIssueModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockQuickCreateStore.keepOpen = false;
mockSetKeepOpen.mockImplementation((v: boolean) => {
mockQuickCreateStore.keepOpen = v;
});
mockCreateIssue.mockResolvedValue({
id: "issue-123",
identifier: "TES-123",
@@ -261,4 +297,44 @@ describe("CreateIssueModal", () => {
expect(mockPush).toHaveBeenCalledWith("/ws-test/issues/issue-123");
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
});
it("keeps manual mode open and clears content when create another is enabled", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
mockQuickCreateStore.keepOpen = true;
renderModal(<CreateIssueModal onClose={onClose} />);
await user.type(screen.getByPlaceholderText("Issue title"), "First follow-up issue");
await user.type(screen.getByPlaceholderText("Add description..."), "Description to clear");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "First follow-up issue",
description: "Description to clear",
status: "todo",
priority: "none",
assignee_type: undefined,
assignee_id: undefined,
due_date: undefined,
attachment_ids: undefined,
parent_issue_id: undefined,
project_id: undefined,
});
});
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByPlaceholderText("Issue title")).toHaveValue("");
expect(screen.getByPlaceholderText("Add description...")).toHaveValue("");
expect(mockSetDraft).toHaveBeenCalledWith({
title: "",
description: "",
status: "todo",
priority: "none",
assigneeType: undefined,
assigneeId: undefined,
dueDate: null,
});
});
});

View File

@@ -30,6 +30,7 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../editor";
import { StatusIcon, StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "../issues/components";
import { BacklogAgentHintContent } from "../issues/components/backlog-agent-hint-dialog";
@@ -38,6 +39,7 @@ import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
@@ -84,8 +86,11 @@ export function ManualCreatePanel({
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
const setLastAssignee = useIssueDraftStore((s) => s.setLastAssignee);
const setLastMode = useCreateModeStore((s) => s.setLastMode);
const keepOpen = useQuickCreateStore((s) => s.keepOpen);
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
const [title, setTitle] = useState(draft.title);
const [formResetKey, setFormResetKey] = useState(0);
const descEditorRef = useRef<ContentEditorRef>(null);
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)),
@@ -138,6 +143,28 @@ export function ManualCreatePanel({
const createIssueMutation = useCreateIssue();
const updateIssueMutation = useUpdateIssue();
const resetForNextIssue = () => {
setTitle("");
setStatus("todo");
setPriority("none");
setDueDate(null);
setProjectId(undefined);
setParentIssueId(undefined);
setChildIssues([]);
setAttachmentIds([]);
setDraft({
title: "",
description: "",
status: "todo",
priority: "none",
assigneeType,
assigneeId,
dueDate: null,
});
descEditorRef.current?.clearContent();
setFormResetKey((key) => key + 1);
};
const handleSubmit = async () => {
if (!title.trim() || submitting) return;
setSubmitting(true);
@@ -186,6 +213,8 @@ export function ManualCreatePanel({
if (shouldShowBacklogHint) {
setBacklogHintIssueId(issue.id);
} else if (keepOpen) {
resetForNextIssue();
} else {
onClose();
}
@@ -304,6 +333,7 @@ export function ManualCreatePanel({
{/* Title */}
<div className="px-5 pb-2 shrink-0">
<TitleEditor
key={formResetKey}
autoFocus
defaultValue={draft.title}
placeholder="Issue title"
@@ -494,20 +524,30 @@ export function ManualCreatePanel({
/>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<FileUploadButton
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<div className="flex flex-col gap-2 border-t px-4 py-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-h-7 items-center gap-2">
<FileUploadButton
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={switchToAgent}
title="Switch to create with agent — describe in one line and let the agent file it"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
className="flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
>
<ArrowLeftRight className="size-3.5" />
Switch to agent
Switch to Agent
</button>
<label className="flex shrink-0 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={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>

View File

@@ -4,6 +4,7 @@ import { useState, useRef } from "react";
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useCreateProject } from "@multica/core/projects/mutations";
import { useProjectDraftStore } from "@multica/core/projects";
import {
PROJECT_STATUS_CONFIG,
PROJECT_STATUS_ORDER,
@@ -63,17 +64,31 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const [title, setTitle] = useState("");
const draft = useProjectDraftStore((s) => s.draft);
const setDraft = useProjectDraftStore((s) => s.setDraft);
const clearDraft = useProjectDraftStore((s) => s.clearDraft);
const [title, setTitle] = useState(draft.title);
const descEditorRef = useRef<ContentEditorRef>(null);
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
const [leadId, setLeadId] = useState<string | undefined>();
const [icon, setIcon] = useState<string | undefined>();
const [status, setStatus] = useState<ProjectStatus>(draft.status);
const [priority, setPriority] = useState<ProjectPriority>(draft.priority);
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType);
const [leadId, setLeadId] = useState<string | undefined>(draft.leadId);
const [icon, setIcon] = useState<string | undefined>(draft.icon);
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Sync field changes to draft store
const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); };
const updateStatus = (v: ProjectStatus) => { setStatus(v); setDraft({ status: v }); };
const updatePriority = (v: ProjectPriority) => { setPriority(v); setDraft({ priority: v }); };
const updateLead = (type?: "member" | "agent", id?: string) => {
setLeadType(type); setLeadId(id);
setDraft({ leadType: type, leadId: id });
};
const updateIcon = (v: string | undefined) => { setIcon(v); setDraft({ icon: v }); };
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
@@ -100,6 +115,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
lead_type: leadType,
lead_id: leadId,
});
clearDraft();
onClose();
toast.success("Project created");
router.push(wsPaths.projectDetail(project.id));
@@ -177,7 +193,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<PopoverContent align="start" className="w-auto p-0">
<EmojiPicker
onSelect={(emoji) => {
setIcon(emoji);
updateIcon(emoji);
setIconPickerOpen(false);
}}
/>
@@ -185,10 +201,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</Popover>
<TitleEditor
autoFocus
defaultValue=""
defaultValue={draft.title}
placeholder="Project title"
className="text-lg font-semibold"
onChange={(v) => setTitle(v)}
onChange={(v) => updateTitle(v)}
onSubmit={handleSubmit}
/>
</div>
@@ -196,8 +212,9 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue=""
defaultValue={draft.description}
placeholder="Add description..."
onUpdate={(md) => setDraft({ description: md })}
debounceMs={500}
/>
</div>
@@ -214,7 +231,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
@@ -233,7 +250,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((pr) => (
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
<DropdownMenuItem key={pr} onClick={() => updatePriority(pr)}>
<PriorityIcon priority={pr} />
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
</DropdownMenuItem>
@@ -276,8 +293,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<button
type="button"
onClick={() => {
setLeadType(undefined);
setLeadId(undefined);
updateLead(undefined, undefined);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@@ -295,8 +311,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
type="button"
key={m.user_id}
onClick={() => {
setLeadType("member");
setLeadId(m.user_id);
updateLead("member", m.user_id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@@ -317,8 +332,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
type="button"
key={a.id}
onClick={() => {
setLeadType("agent");
setLeadId(a.id);
updateLead("agent", a.id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"

View File

@@ -16,7 +16,7 @@ import {
useFileDropZone,
FileDropOverlay,
} from "../editor";
import { useCreateFeedback } from "@multica/core/feedback";
import { useCreateFeedback, useFeedbackDraftStore } from "@multica/core/feedback";
import { useCurrentWorkspace } from "@multica/core/paths";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
@@ -26,8 +26,12 @@ const MAX_MESSAGE_LEN = 10000;
export function FeedbackModal({ onClose }: { onClose: () => void }) {
const workspace = useCurrentWorkspace();
const draft = useFeedbackDraftStore((s) => s.draft);
const setDraft = useFeedbackDraftStore((s) => s.setDraft);
const clearDraft = useFeedbackDraftStore((s) => s.clearDraft);
const editorRef = useRef<ContentEditorRef>(null);
const [message, setMessage] = useState("");
const [message, setMessage] = useState(draft.message);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
});
@@ -69,6 +73,7 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
url: typeof window !== "undefined" ? window.location.href : undefined,
workspace_id: workspace?.id,
});
clearDraft();
toast.success("Thanks for the feedback!");
onClose();
} catch (err) {
@@ -98,8 +103,9 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
>
<ContentEditor
ref={editorRef}
defaultValue={draft.message}
placeholder="Tell us about your experience, bugs you've found, or features you'd like to see…"
onUpdate={(md) => setMessage(md)}
onUpdate={(md) => { setMessage(md); setDraft({ message: md }); }}
onUploadFile={uploadWithToast}
onSubmit={handleSubmit}
debounceMs={150}

View File

@@ -252,6 +252,7 @@ export function AgentCreatePanel({
on the first focusable element on mount, causing the tooltip to
auto-pop every open. */}
<button
type="button"
onClick={onClose}
title="Close"
aria-label="Close"
@@ -268,6 +269,7 @@ export function AgentCreatePanel({
render={
<button
type="button"
aria-label="Select agent"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-sm px-1.5 py-1 -ml-1.5 hover:bg-accent/60"
>
<span>Created by</span>
@@ -307,6 +309,9 @@ export function AgentCreatePanel({
size={16}
/>
<span className="flex-1 truncate">{a.name}</span>
{agentId === a.id && (
<Check className="size-3.5 text-muted-foreground" />
)}
</DropdownMenuItem>
))
)}
@@ -350,30 +355,30 @@ export function AgentCreatePanel({
)}
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<div className="flex items-center gap-1.5">
<div className="flex flex-col gap-2 border-t px-4 py-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-h-7 items-center gap-2">
<FileUploadButton
size="sm"
disabled={uploading}
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>
{keepOpen && sentCount > 0 && (
<span className="text-xs text-emerald-600 dark:text-emerald-400">
{sentCount} sent
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={switchToManual}
title="Switch to manual create — fill the fields yourself"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
className="flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
>
<ArrowLeftRight className="size-3.5" />
Switch to manual
Switch to Manual
</button>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<Switch
size="sm"
checked={keepOpen}
@@ -390,11 +395,11 @@ export function AgentCreatePanel({
? `Daemon CLI must be ≥ ${versionCheck.min}`
: undefined
}
className={justSent ? "!bg-emerald-600 !text-white" : undefined}
className={justSent ? "min-w-28 !bg-emerald-600 !text-white" : "min-w-28"}
>
{submitting ? "Sending…" : uploading ? "Uploading…" : justSent ? (
<span className="flex items-center gap-1"><Check className="size-3.5" />Sent</span>
) : "Create"}
) : "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

@@ -60,12 +60,10 @@ export interface RuntimeRow {
canDelete: boolean;
}
// Column widths in px. The Runtime column has `meta.grow: true` so
// DataTable skips its inline width — fixed table-layout assigns it the
// leftover space. Its `size: 240` still flows into table.getTotalSize()
// to set the table's `min-width`, giving the runtime column a 240px
// floor below which the container scrolls horizontally instead of
// shrinking the column further.
// Column widths in px. Runtime, Health, and CLI grow together until the
// user resizes them. Their `size` values still flow into table.getTotalSize()
// to set the table's min-width, giving each grow column a real floor below
// which the container scrolls horizontally instead of shrinking further.
const COL_WIDTHS = {
runtime: 240,
health: 200,
@@ -105,6 +103,7 @@ export function createRuntimeColumns({
id: "health",
header: "Health",
size: COL_WIDTHS.health,
meta: { grow: true },
cell: ({ row }) => (
<HealthCell runtime={row.original.runtime} now={now} />
),
@@ -164,6 +163,7 @@ export function createRuntimeColumns({
id: "cli",
header: "CLI",
size: COL_WIDTHS.cli,
meta: { grow: true },
cell: ({ row }) => (
<CliCell
runtime={row.original.runtime}
@@ -175,6 +175,7 @@ export function createRuntimeColumns({
id: "actions",
header: () => null,
size: COL_WIDTHS.actions,
enableResizing: false,
cell: ({ row }) => (
<div
className="flex justify-end"

View File

@@ -149,6 +149,7 @@ export function RuntimeList({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
enableColumnResizing: true,
// Pin the kebab column right so it stays accessible during horizontal
// scroll — matches the pattern in Linear / Notion / GitHub.
initialState: { columnPinning: { right: ["actions"] } },

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

@@ -102,6 +102,7 @@ export function createSkillColumns(): ColumnDef<SkillRow>[] {
id: "_chevron",
header: () => null,
size: COL_WIDTHS.chevron,
enableResizing: false,
cell: () => (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground" />
),

View File

@@ -288,6 +288,7 @@ export default function SkillsPage() {
data: skillRows,
columns,
getCoreRowModel: getCoreRowModel(),
enableColumnResizing: true,
});
// --- Loading ---