Compare commits

..

5 Commits

Author SHA1 Message Date
Lambda
51074acbc6 fix(runtimes): use Simple Icons Google Gemini mark (MUL-2447)
Drop the hand-crafted aurora gradient approximation and inline the
canonical "Google Gemini" path from Simple Icons (CC0 1.0), rendered
in the Simple Icons brand color (#8E75B2). This matches the pattern
used by the other provider marks in this file (Claude/Codex from
Bootstrap Icons, etc.) instead of trying to manually approximate the
official multicolor wash from gemini.google.com (which paints via a
clipPath over an embedded raster).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 12:16:25 +08:00
Lambda
624a639e03 fix(runtimes): align Gemini aurora color positions and smooth spark path
Swap yellow/green radial gradient anchors so colors land at the official
positions: top red / right blue / left yellow / bottom green, matching
gemini.google.com's current aurora spark. Replace the arc-based 4-point
spark outline with a cubic-bezier version normalized to the 24-viewBox
so the inset between tips is smoother and closer to the gstatic source.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 12:08:27 +08:00
Lambda
4add27dbba fix(runtimes): switch Gemini icon to aurora multicolor treatment (MUL-2447)
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 11:57:46 +08:00
Lambda
da0b23357c fix(runtimes): use current Gemini multicolor spark gradient (MUL-2447)
Per review on PR #2904: the previous 3-stop blue/purple/pink gradient
was the legacy Bard-era Gemini spark. Update to the 5-stop cyan → blue
→ purple → pink → orange gradient used by the current Gemini app/web
multicolor mark.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 11:48:37 +08:00
Lambda
32e873894c fix(runtimes): use official Gemini spark icon (MUL-2447)
Gemini provider was falling through to the default Monitor icon in the
runtime list. Add the official 4-point spark mark with Google's
blue → purple → pink gradient, matching the SVG style/sizing of the
other provider icons.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 11:41:41 +08:00
192 changed files with 3474 additions and 13689 deletions

View File

@@ -91,20 +91,3 @@ jobs:
- name: Test
run: cd server && go test ./...
installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
# backend job so installer regressions surface independently, and
# exercised on macOS too because the installer targets macOS/Homebrew
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Test shell installers
run: bash scripts/install.test.sh

View File

@@ -373,44 +373,9 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
### Comments
```bash
# List comments — flat timeline, chronological. Hard cap of 2000 rows; on
# long-running issues prefer one of the thread-aware reads below to keep
# context windows tight.
# List comments
multica issue comment list <issue-id>
# Single thread (root + every descendant). Anchor may be the root itself
# or any reply inside the thread — the server walks up to the root.
multica issue comment list <issue-id> --thread <comment-id>
# Single thread, capped to the N most recent replies. The thread root is
# always included (even with --tail 0), so an agent landing on a long
# thread keeps the "what is this about" context without dragging hundreds
# of replies into its prompt.
multica issue comment list <issue-id> --thread <comment-id> --tail 30
# Scroll older replies inside the same thread. --before / --before-id are
# the reply cursor that the previous response emitted on stderr as
# `Next reply cursor: --before <ts> --before-id <reply-id>`.
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--before <ts> --before-id <reply-id>
# Most recently active threads (root + every descendant), grouped by
# thread. Returns N complete conversational arcs, oldest-active first so
# the freshest thread sits closest to "now" in an agent prompt.
multica issue comment list <issue-id> --recent 20
# Scroll older threads. Under --recent, --before / --before-id are a
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
# `Next thread cursor: --before <ts> --before-id <root-id>`.
multica issue comment list <issue-id> --recent 20 \
--before <ts> --before-id <root-id>
# Incremental polling. Combines with --thread or --recent; filters out
# replies created on or before <ts> from the page (the thread root is
# exempt so the agent always gets context).
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--since <RFC3339-timestamp>
# Add a comment
multica issue comment add <issue-id> --content "Looks good, merging now"
@@ -421,29 +386,6 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
**`--before` / `--before-id` semantics depend on the paging mode**, by
design — same flag, different scope:
| Mode | What the cursor walks | stderr label |
| --- | --- | --- |
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
Outside those two modes (`--thread` without `--tail`, or no `--thread`
and no `--recent`) the cursor flags are rejected so they cannot silently
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
`X-Multica-Next-Before-Id`) only when an older page actually exists —
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
replies) intentionally return no cursor so callers stop paginating.
When `--since` is combined with `--recent` or `--thread --tail`, the
server additionally suppresses the cursor once the cursor target itself
is older than `since`. Older pages walk strictly older rows, so they
cannot satisfy `> since` either — emitting a cursor there would just
hand back root-only pages until the caller reaches the start of the
thread / issue. Incremental polling stops at the first page whose
cursor target falls before the watermark.
### Subscribers
```bash

View File

@@ -79,7 +79,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |

View File

@@ -19,28 +19,10 @@ import type { DaemonStatus } from "../../../shared/daemon-types";
*/
export function DesktopRuntimesPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
// Remember the last known daemonId/deviceName. After the daemon is
// stopped, `status.daemonId` goes back to undefined — without this
// sticky cache the local row would either disappear or get reclassified
// as a remote machine (since `isCurrent` requires a daemonId match),
// taking the Start button with it.
const [lastIdentity, setLastIdentity] = useState<{
daemonId: string | null;
deviceName: string | null;
}>({ daemonId: null, deviceName: null });
useEffect(() => {
const apply = (s: DaemonStatus) => {
setStatus(s);
if (s.daemonId) {
setLastIdentity({
daemonId: s.daemonId,
deviceName: s.deviceName ?? null,
});
}
};
window.daemonAPI.getStatus().then(apply);
return window.daemonAPI.onStatusChange(apply);
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const bootstrapping =
@@ -50,14 +32,9 @@ export function DesktopRuntimesPage() {
return (
<RuntimesPage
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
localMachineName={status.deviceName ?? lastIdentity.deviceName}
localDaemonId={status.daemonId ?? null}
localMachineName={status.deviceName ?? null}
localMachineActions={<DaemonRuntimeActions />}
// Desktop owns a local machine for the lifetime of the app, even
// while the daemon is stopped or hasn't registered yet. The shared
// page synthesizes a placeholder local row when no real runtime
// matches, so the Start button is always reachable.
hasLocalMachine
bootstrapping={bootstrapping}
/>
);

View File

@@ -1,151 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, fireEvent, within } from "@testing-library/react";
type MockTab = {
id: string;
path: string;
title: string;
icon: string;
pinned: boolean;
};
const state = vi.hoisted(() => ({
activeWorkspaceSlug: "acme" as string | null,
byWorkspace: {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
] as MockTab[],
},
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
togglePin: vi.fn<(tabId: string) => void>(),
closeTab: vi.fn<(tabId: string) => void>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
moveTab: vi.fn<(from: number, to: number) => void>(),
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
}));
vi.mock("@/stores/tab-store", () => {
const store = {
get activeWorkspaceSlug() {
return state.activeWorkspaceSlug;
},
get byWorkspace() {
return state.byWorkspace;
},
togglePin: state.togglePin,
closeTab: state.closeTab,
setActiveTab: state.setActiveTab,
moveTab: state.moveTab,
addTab: state.addTab,
};
const useTabStore = Object.assign(
(selector?: (s: typeof store) => unknown) =>
selector ? selector(store) : store,
{ getState: () => store },
);
const useActiveGroup = () =>
state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug] ?? null)
: null;
const resolveRouteIcon = () => "ListTodo";
return { useTabStore, useActiveGroup, resolveRouteIcon };
});
vi.mock("@multica/core/paths", () => ({
paths: {
workspace: (slug: string) => ({
issues: () => `/${slug}/issues`,
}),
},
}));
import { TabBar } from "./tab-bar";
function reset() {
state.activeWorkspaceSlug = "acme";
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
],
},
};
state.togglePin.mockReset();
state.closeTab.mockReset();
state.setActiveTab.mockReset();
state.moveTab.mockReset();
state.addTab.mockReset();
}
beforeEach(reset);
describe("TabBar hover action buttons", () => {
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getAllByLabelText } = render(<TabBar />);
expect(getAllByLabelText("Unpin tab")).toHaveLength(1);
expect(getAllByLabelText("Pin tab")).toHaveLength(1);
});
it("clicking the Pin button calls togglePin for the tab", () => {
const { getAllByLabelText } = render(<TabBar />);
const pinButtons = getAllByLabelText("Pin tab");
fireEvent.click(pinButtons[1]); // click Pin on tB (Projects)
expect(state.togglePin).toHaveBeenCalledWith("tB");
});
it("clicking the Unpin button on a pinned tab calls togglePin", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getByLabelText } = render(<TabBar />);
fireEvent.click(getByLabelText("Unpin tab"));
expect(state.togglePin).toHaveBeenCalledWith("tA");
});
it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { queryAllByLabelText } = render(<TabBar />);
// Only the unpinned tab exposes a Close affordance — pinned tab requires
// explicit Unpin first (RFC §3 D3c FINAL).
expect(queryAllByLabelText("Close tab")).toHaveLength(1);
});
it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
];
const { getByLabelText } = render(<TabBar />);
const pinnedTab = getByLabelText("Issues (pinned)");
expect(within(pinnedTab).getByText("Issues")).toBeTruthy();
});
it("renders the Pin glyph as the leading icon on a pinned tab and the route icon on an unpinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getByLabelText } = render(<TabBar />);
const pinnedTab = getByLabelText("Issues (pinned)");
const unpinnedTab = getByLabelText("Projects");
// lucide-react renders the icon name into the class list. The leading
// slot icon is size-3.5; the hover Pin/Unpin action button is size-2.5,
// so we qualify on size to avoid matching the action glyph.
expect(pinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeTruthy();
expect(pinnedTab.querySelector(".lucide-list-todo")).toBeNull();
expect(unpinnedTab.querySelector(".lucide-list-todo.size-3\\.5")).toBeTruthy();
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
});
});

View File

@@ -1,4 +1,3 @@
import { Fragment } from "react";
import {
Inbox,
CircleUser,
@@ -9,8 +8,6 @@ import {
Settings,
X,
Plus,
Pin,
PinOff,
type LucideIcon,
} from "lucide-react";
import {
@@ -31,20 +28,8 @@ import {
restrictToParentElement,
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@multica/ui/components/ui/context-menu";
import { cn } from "@multica/ui/lib/utils";
import {
useTabStore,
useActiveGroup,
resolveRouteIcon,
type Tab,
} from "@/stores/tab-store";
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import { paths } from "@multica/core/paths";
const TAB_ICONS: Record<string, LucideIcon> = {
@@ -57,23 +42,9 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Settings,
};
function SortableTabItem({
tab,
isActive,
isOnly,
}: {
tab: Tab;
isActive: boolean;
/**
* True iff this is the only tab in the workspace. Hiding X on the last
* tab matches existing behavior and avoids the surprise of the store's
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
*/
isOnly: boolean;
}) {
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const closeTab = useTabStore((s) => s.closeTab);
const togglePin = useTabStore((s) => s.togglePin);
const {
attributes,
@@ -84,11 +55,7 @@ function SortableTabItem({
isDragging,
} = useSortable({ id: tab.id });
// Pinned tabs swap the route icon for a Pin glyph as the static "I am
// pinned" indicator (RFC §3 D1v-iv FINAL). The route information is still
// present in the title, and this avoids a hard left accent border that read
// as visually heavy in light mode.
const LeadingIcon = tab.pinned ? Pin : TAB_ICONS[tab.icon];
const Icon = TAB_ICONS[tab.icon];
const style = {
transform: CSS.Transform.toString(transform),
@@ -107,30 +74,17 @@ function SortableTabItem({
closeTab(tab.id);
};
const handleTogglePin = (e: React.MouseEvent) => {
e.stopPropagation();
togglePin(tab.id);
};
const stopDragOnAction = (e: React.PointerEvent) => {
const stopDragOnClose = (e: React.PointerEvent) => {
e.stopPropagation();
};
// Pinned tabs keep their full title (RFC §3 D1v-ii FINAL). The only visual
// differences vs. unpinned tabs are the leading Pin icon (swapped in above)
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
// reachable via the hover action button below and the right-click menu.
const showCloseButton = !tab.pinned && !isOnly;
const tabButton = (
return (
<button
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
className={cn(
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"select-none cursor-default",
@@ -140,7 +94,7 @@ function SortableTabItem({
isDragging && "opacity-60",
)}
>
{LeadingIcon && <LeadingIcon className="size-3.5 shrink-0" />}
{Icon && <Icon className="size-3.5 shrink-0" />}
<span
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
style={{
@@ -150,22 +104,10 @@ function SortableTabItem({
>
{tab.title}
</span>
<span
onClick={handleTogglePin}
onPointerDown={stopDragOnAction}
role="button"
aria-label={tab.pinned ? "Unpin tab" : "Pin tab"}
title={tab.pinned ? "Unpin tab" : "Pin tab"}
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
{tab.pinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
</span>
{showCloseButton && (
{!isOnly && (
<span
onClick={handleClose}
onPointerDown={stopDragOnAction}
role="button"
aria-label="Close tab"
onPointerDown={stopDragOnClose}
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
<X className="size-2.5" />
@@ -173,36 +115,6 @@ function SortableTabItem({
)}
</button>
);
return (
<ContextMenu>
<ContextMenuTrigger render={tabButton} />
<ContextMenuContent>
<ContextMenuItem onClick={() => togglePin(tab.id)}>
{tab.pinned ? (
<>
<PinOff />
Unpin tab
</>
) : (
<>
<Pin />
Pin tab
</>
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={tab.pinned || isOnly}
onClick={() => closeTab(tab.id)}
>
<X />
Close tab
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function NewTabButton() {
@@ -243,17 +155,12 @@ export function TabBar() {
const tabs = group?.tabs ?? [];
const activeTabId = group?.activeTabId ?? "";
const tabIds = tabs.map((t) => t.id);
const pinnedCount = tabs.filter((t) => t.pinned).length;
const unpinnedCount = tabs.length - pinnedCount;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = tabs.findIndex((t) => t.id === active.id);
const to = tabs.findIndex((t) => t.id === over.id);
// The store clamps the destination to within the source tab's zone
// (pinned vs unpinned), so this call is safe even when the user tries
// to drag across the boundary — the tab will land at the boundary.
if (from !== -1 && to !== -1) moveTab(from, to);
};
@@ -266,22 +173,13 @@ export function TabBar() {
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab, index) => (
<Fragment key={tab.id}>
<SortableTabItem
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
{tab.pinned &&
index === pinnedCount - 1 &&
unpinnedCount > 0 && (
<div
aria-hidden
className="mx-1 h-4 w-px bg-border"
/>
)}
</Fragment>
{tabs.map((tab) => (
<SortableTabItem
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
))}
</SortableContext>
</DndContext>

View File

@@ -5,40 +5,17 @@ import { useEffect } from "react";
// Shared in-memory state that the mocked tab store reads / mutates. The test
// records every method call so we can assert openInNewTab does NOT activate
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
type MockRouter = {
state: { location: { pathname: string } };
navigate: ReturnType<typeof vi.fn>;
};
type MockTab = {
id: string;
path: string;
pinned: boolean;
router: MockRouter;
};
function makeMockRouter(pathname: string): MockRouter {
return {
state: { location: { pathname } },
navigate: vi.fn(),
};
}
const state = vi.hoisted(() => ({
activeWorkspaceSlug: "acme" as string | null,
byWorkspace: {
acme: {
activeTabId: "tA",
tabs: [
{
id: "tA",
path: "/acme/issues",
pinned: false,
router: makeMockRouter("/acme/issues"),
},
] as MockTab[],
tabs: [{ id: "tA", path: "/acme/issues" }],
},
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
} as Record<
string,
{ activeTabId: string; tabs: { id: string; path: string }[] }
>,
openTab: vi.fn<(path: string, title?: string, icon?: string) => string>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
switchWorkspace: vi.fn<(slug: string, openPath?: string) => void>(),
@@ -114,14 +91,7 @@ beforeEach(() => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{
id: "tA",
path: "/acme/issues",
pinned: false,
router: makeMockRouter("/acme/issues"),
},
],
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
Object.defineProperty(window, "desktopAPI", {
@@ -200,69 +170,6 @@ describe("DesktopNavigationProvider.openInNewTab", () => {
});
});
describe("DesktopNavigationProvider.push with pinned active tab", () => {
function pinActive(pathname: string) {
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: pathname,
pinned: true,
router: makeMockRouter(pathname),
};
}
it("redirects push to a new foreground tab when pathname differs", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/projects");
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
});
it("allows in-tab navigation when only search/hash changes", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/issues?filter=open");
// Pathname unchanged → pinned interception declines and falls through to
// the router's own navigate — openTab / setActiveTab must not fire.
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
});
it("leaves cross-workspace push to the workspace switcher (not pin)", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/butter/inbox");
// Cross-workspace push runs through tryRouteToOtherWorkspace before
// tryRouteToPinnedNewTab, so switchWorkspace wins.
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
expect(state.openTab).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.openInNewTab", () => {
function renderTabProvider() {
let adapter: ReturnType<typeof useNavigation> | null = null;
@@ -298,58 +205,3 @@ describe("TabNavigationProvider.openInNewTab", () => {
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.push with pinned active tab", () => {
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
function renderPinnedTabProvider(pathname: string) {
// The active tab and the per-tab router must share the same pathname:
// tryRouteToPinnedNewTab reads the *active tab's* router for the current
// pathname (so query-only pushes routed via React Router still compare
// correctly), while the TabNavigationProvider falls back to *its own*
// router.navigate when no interception fires. In real desktop usage they
// are the same router instance; this helper mirrors that invariant.
const fakeRouter = {
state: { location: { pathname, search: "" } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as ProviderRouter;
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: pathname,
pinned: true,
router: fakeRouter as unknown as MockRouter,
};
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return { getAdapter: () => adapter!, fakeRouter };
}
it("redirects push to a new foreground tab when pathname differs", () => {
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
getAdapter().push("/acme/projects");
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
// Pinned interception short-circuits — the per-tab router must NOT
// navigate, otherwise the pinned tab itself would move off its path.
expect(fakeRouter.navigate).not.toHaveBeenCalled();
});
it("allows in-tab navigation when only search/hash changes", () => {
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
getAdapter().push("/acme/issues?filter=open");
// Same pathname → pinned interception declines, push falls through to
// the tab's own router.navigate, and no new tab is opened.
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open");
});
});

View File

@@ -108,37 +108,6 @@ function tryRouteToOtherWorkspace(path: string): boolean {
return true;
}
/**
* Intercept pushes originating in a pinned tab and force them into a new
* tab. Returns `true` if the navigation was redirected (caller should NOT
* proceed). Pathname-only changes (search / hash / same-page state) are
* allowed through so pinned filter / drawer / form-state interactions
* still work — see RFC §3 D2a (FINAL: any pathname change → new tab) and
* D2b (FINAL: same pathname → allowed in pinned tab).
*
* Dedupe is preserved (D4a): `openTab` activates an existing same-path tab
* if one exists, otherwise creates a new one. The newly-focused tab is
* activated foreground — a pinned-tab push is an explicit user action, not
* a background cmd+click, so the focus follows.
*/
function tryRouteToPinnedNewTab(path: string): boolean {
const store = useTabStore.getState();
const active = getActiveTab(store);
if (!active?.pinned) return false;
// Use the live router pathname rather than `active.path` so query-only
// navigations performed via React Router (which only sync pathname back
// to the store) still compare correctly.
const currentPathname = active.router.state.location.pathname;
const newPathname = path.split("?")[0].split("#")[0];
if (currentPathname === newPathname) return false;
const icon = resolveRouteIcon(path);
const newId = store.openTab(path, path, icon);
if (newId) store.setActiveTab(newId);
return true;
}
/**
* Root-level navigation provider for components outside the per-tab
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
@@ -196,7 +165,6 @@ export function DesktopNavigationProvider({
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
active?.router.navigate(path);
},
replace: (path: string) => {
@@ -272,7 +240,6 @@ export function TabNavigationProvider({
push: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
router.navigate(path);
},
replace: (path: string) => {

View File

@@ -17,7 +17,6 @@ vi.mock("../routes", () => ({
import {
sanitizeTabPath,
migrateV1ToV2,
migrateV2ToV3,
useTabStore,
} from "./tab-store";
@@ -278,155 +277,3 @@ describe("useTabStore actions", () => {
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
});
});
describe("togglePin", () => {
it("flips a tab's pinned state", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
});
it("moves a newly-pinned tab to the start of the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs[0].id).toBe(agentsId);
expect(tabs[0].pinned).toBe(true);
expect(tabs[1].pinned).toBe(false);
expect(tabs[2].pinned).toBe(false);
});
it("appends a second pinned tab after the first pinned tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
store.togglePin(projectsId);
// Both pinned, in the order they were pinned (agents first, projects
// second), then the unpinned default tab.
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([
agentsId,
projectsId,
tabs[2].id,
]);
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
});
it("returns an unpinned tab to the start of the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
// Pin both, then unpin one.
store.togglePin(issuesId);
store.togglePin(projectsId);
store.togglePin(issuesId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
});
});
describe("moveTab boundary clamp", () => {
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag the pinned tab to index 2 (unpinned zone end).
store.moveTab(0, 2);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
expect(tabs[0].id).toBe(issuesId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag agents (index 2) to index 0 (pinned zone).
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// Clamped to index 1 — start of the unpinned zone.
expect(tabs[0].id).toBe(issuesId);
expect(tabs[1].id).toBe(agentsId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("reorders freely within the same zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
// All unpinned; move agents (2) to position 0.
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.path)).toEqual([
"/acme/agents",
"/acme/issues",
"/acme/projects",
]);
});
});
describe("migrateV2ToV3", () => {
it("adds pinned=false to every persisted tab", () => {
const v2 = {
activeWorkspaceSlug: "acme",
byWorkspace: {
acme: {
activeTabId: "t1",
tabs: [
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
],
},
},
};
const v3 = migrateV2ToV3(v2);
expect(v3.activeWorkspaceSlug).toBe("acme");
expect(v3.byWorkspace.acme.tabs).toEqual([
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
]);
});
it("handles missing byWorkspace gracefully", () => {
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
expect(v3.byWorkspace).toEqual({});
expect(v3.activeWorkspaceSlug).toBeNull();
});
});

View File

@@ -20,14 +20,6 @@ export interface Tab {
router: DataRouter;
historyIndex: number;
historyLength: number;
/**
* Pinned tabs render at the left of the tab bar as icon-only, suppress the
* X close button, and turn any `navigation.push()` originating in them into
* an `openInNewTab()` so they stay parked on their original path. Pinning
* is invariant-preserving: pinned tabs always come before unpinned tabs in
* a workspace's `tabs` array; `togglePin` / `moveTab` enforce this.
*/
pinned: boolean;
}
export interface WorkspaceTabGroup {
@@ -86,20 +78,8 @@ interface TabStore {
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Patch history tracking of a tab. Finds across groups. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/**
* Reorder within the active workspace's group only. Clamped so a tab can
* never cross the pinned / unpinned boundary — a drag that would move a
* pinned tab into the unpinned zone (or vice versa) is dropped at the
* boundary instead. This keeps the "pinned tabs first" invariant without
* requiring callers to know about it.
*/
/** Reorder within the active workspace's group only. */
moveTab: (fromIndex: number, toIndex: number) => void;
/**
* Flip a tab's pinned state. Pinning moves it to the end of the pinned
* zone; unpinning moves it to the start of the unpinned zone. Both
* preserve the "pinned tabs before unpinned tabs" invariant.
*/
togglePin: (tabId: string) => void;
/**
* After the workspace list arrives/changes (login, realtime delete), drop
* any tab group whose slug is no longer in `validSlugs`, and repoint
@@ -210,17 +190,9 @@ function makeTab(path: string, title: string, icon: string): Tab {
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
pinned: false,
};
}
/** Index of the first unpinned tab in a group (== pinned count). */
function pinnedBoundary(tabs: Tab[]): number {
let i = 0;
while (i < tabs.length && tabs[i].pinned) i++;
return i;
}
/** Default entry point for a workspace — its issues list. */
function defaultPathFor(slug: string): string {
return `/${slug}/issues`;
@@ -481,63 +453,17 @@ export const useTabStore = create<TabStore>()(
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
if (fromIndex < 0 || fromIndex >= group.tabs.length) return;
// Clamp the drop position to within the source tab's group (pinned vs
// unpinned) so the "pinned tabs first" invariant survives drag-reorder.
// Pinned zone is [0, boundary); unpinned zone is [boundary, length).
const boundary = pinnedBoundary(group.tabs);
const source = group.tabs[fromIndex];
let clampedTo: number;
if (source.pinned) {
// boundary is exclusive upper bound for pinned-zone indices.
clampedTo = Math.max(0, Math.min(toIndex, boundary - 1));
} else {
clampedTo = Math.max(boundary, Math.min(toIndex, group.tabs.length - 1));
}
if (clampedTo === fromIndex) return;
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
...group,
tabs: arrayMove(group.tabs, fromIndex, clampedTo),
tabs: arrayMove(group.tabs, fromIndex, toIndex),
},
},
});
},
togglePin(tabId) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const nextTab: Tab = { ...current, pinned: !current.pinned };
// Remove from current position, then insert at the new zone boundary:
// pinning → end of pinned zone (just before first unpinned tab)
// unpinning → start of unpinned zone (right after last pinned tab)
const withoutCurrent = [
...group.tabs.slice(0, index),
...group.tabs.slice(index + 1),
];
const newBoundary = pinnedBoundary(withoutCurrent);
const insertAt = newBoundary;
const nextTabs = [
...withoutCurrent.slice(0, insertAt),
nextTab,
...withoutCurrent.slice(insertAt),
];
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
validateWorkspaceSlugs(validSlugs) {
const { activeWorkspaceSlug, byWorkspace } = get();
let changed = false;
@@ -571,23 +497,17 @@ export const useTabStore = create<TabStore>()(
}),
{
name: "multica_tabs",
version: 3,
version: 2,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
migrate: (persistedState, version) => {
// v1 → v2: flat `tabs` array → per-workspace grouping.
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
// are dropped — they have no workspace to belong to, and the new
// model's invariant is "every tab lives in a workspace group".
let state = persistedState;
if (version < 2 && state && typeof state === "object") {
state = migrateV1ToV2(state as Partial<V1Persisted>);
if (version < 2 && persistedState && typeof persistedState === "object") {
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
}
// v2 → v3: introduce `Tab.pinned`. Existing tabs default to
// unpinned; pin ordering invariant trivially holds (no pinned tabs).
if (version < 3 && state && typeof state === "object") {
state = migrateV2ToV3(state as V2Persisted);
}
return state as V3Persisted;
return persistedState as V2Persisted;
},
partialize: (state) => ({
activeWorkspaceSlug: state.activeWorkspaceSlug,
@@ -597,19 +517,15 @@ export const useTabStore = create<TabStore>()(
{
activeTabId: group.activeTabId,
tabs: group.tabs.map(
({
router: _router,
historyIndex: _hi,
historyLength: _hl,
...rest
}) => rest,
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
rest,
),
},
]),
),
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<V3Persisted> | undefined;
const persisted = persistedState as Partial<V2Persisted> | undefined;
if (!persisted?.byWorkspace) return currentState;
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
@@ -636,14 +552,9 @@ export const useTabStore = create<TabStore>()(
router: createTabRouter(clean),
historyIndex: 0,
historyLength: 1,
pinned: pTab.pinned === true,
});
}
if (tabs.length === 0) continue;
// Enforce the "pinned first" invariant on rehydration in case a
// user (or a buggy older write) persisted the pinned tabs out of
// order. Stable sort preserves intra-group order.
tabs.sort((a, b) => (a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1));
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
? pGroup.activeTabId
: tabs[0].id;
@@ -694,38 +605,6 @@ interface V2Persisted {
byWorkspace: Record<string, V2PersistedGroup>;
}
interface V3PersistedTab {
id: string;
path: string;
title: string;
icon: string;
pinned: boolean;
}
interface V3PersistedGroup {
tabs: V3PersistedTab[];
activeTabId: string;
}
interface V3Persisted {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, V3PersistedGroup>;
}
export function migrateV2ToV3(v2: V2Persisted): V3Persisted {
const byWorkspace: Record<string, V3PersistedGroup> = {};
for (const [slug, group] of Object.entries(v2.byWorkspace ?? {})) {
byWorkspace[slug] = {
activeTabId: group.activeTabId,
tabs: group.tabs.map((t) => ({ ...t, pinned: false })),
};
}
return {
activeWorkspaceSlug: v2.activeWorkspaceSlug ?? null,
byWorkspace,
};
}
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
const byWorkspace: Record<string, V2PersistedGroup> = {};
const oldTabs = v1.tabs ?? [];

View File

@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
import { Callout } from "fumadocs-ui/components/callout";
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/projects). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
## What an agent can do
@@ -14,7 +14,7 @@ Agents use the same "member" surface as humans, and the UI barely distinguishes
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
- **Lead a [project](/projects)** — it can be set as project lead, same as a human
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.

View File

@@ -5,7 +5,7 @@ description: 智能体agent是 Multica 工作区里的一等公民成员
import { Callout } from "fumadocs-ui/components/callout";
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/projects) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
## 智能体能做什么
@@ -14,7 +14,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **[被分配 issue](/assigning-issues)** —— 作为 assignee分配后它会自动开工
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
- **作为 [project](/projects) 的负责人** —— 和人一样能被设为 project lead
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。

View File

@@ -72,7 +72,7 @@ multica daemon status
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't connect to the server](/troubleshooting#daemon-cant-connect-to-the-server).
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
## 5. Create an agent

View File

@@ -219,7 +219,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |

View File

@@ -77,9 +77,8 @@ multica issue rerun <issue-id>
Behavior:
- By default, targets the issue's **current agent assignee** — useful when you want the rerun to follow the current assignment regardless of who ran the prior task.
- The execution-log retry button on a specific row sends that row's task ID alongside, so the rerun targets **the agent that ran that exact task** — not the current assignee. This makes per-row retry meaningful for squad workers, parallel @-mention agents, or rows whose agent has since been displaced by a reassignment.
- **Cancels** the target agent's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
- Targets the issue's **current agent assignee** — not whoever ran the most recent task. If the assignee changed since the last run, rerun follows the current assignment. To rerun a specific agent that is no longer the assignee, reassign the issue first, then rerun.
- **Cancels** the assignee's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling.
- Starts a **fresh agent session** — the prior session ID is **not** inherited. A manual rerun means you've judged the previous output bad, so resuming the same conversation would replay the same poisoned state. (Automatic retry, by contrast, does inherit the session — that path is for infrastructure failures, not bad output.)
@@ -90,7 +89,7 @@ Comparison:
| Trigger | System, based on failure reason | You, manually |
| Ceiling | 2 attempts | No limit |
| Applicable sources | Issues, chat | Issues with an agent assignee |
| Agent picked | Same agent as the failed task | Source task's agent (UI per-row retry) or issue's current assignee (CLI / no task_id) |
| Agent picked | Same agent as the failed task | Issue's current assignee |
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
## How a failed task affects issue status

View File

@@ -77,9 +77,8 @@ multica issue rerun <issue-id>
行为:
- 默认跑的是 issue **当前的智能体分配人**——适用于希望 rerun 跟随当前分配人的场景
- 执行日志里某一行的 retry 按钮会把这一行的 task ID 一并发出rerun 会**针对那一行原本的 agent**,而不是当前分配人。这让 squad worker、并行的 @-mention agent、或者已经被新分配人替代的旧任务行的 retry 按钮都能符合直觉地工作
- **取消**目标 agent 在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消
- 创建一个**全新**的执行任务——尝试次数重置为 1即使原任务已达最大尝试。
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行再继续之前的对话只会重放被污染的上下文。自动重试则相反会继承会话——那条路径处理的是基础设施层面的失败不是产出不好。
@@ -90,7 +89,7 @@ multica issue rerun <issue-id>
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
| 上限 | 2 次 | 无上限 |
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
| 跑哪个 agent | 失败任务原本的 agent | UI 单行 retry那一行任务的 agentCLI / 不带 task_idissue 当前的分配人 |
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
## 失败的任务对 issue 状态有什么影响

View File

@@ -1,8 +1 @@
import { RuntimesPage } from "@multica/views/runtimes";
const cloudRuntimeEnabled =
process.env.NEXT_PUBLIC_ENABLE_CLOUD_RUNTIME === "true";
export default function RuntimesRoute() {
return <RuntimesPage cloudRuntimeEnabled={cloudRuntimeEnabled} />;
}
export { RuntimesPage as default } from "@multica/views/runtimes";

View File

@@ -284,35 +284,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.4",
date: "2026-05-20",
title: "Smarter Autopilots, Agent Controls & Desktop Reliability",
changes: [],
features: [
"Autopilots can assign new work through squads and place created Issues directly into a selected Project",
"Agent settings now include per-agent thinking controls for Claude and Codex, with an inspector picker that updates instantly",
"Desktop tabs can be pinned so important workspace pages stay parked while new links open in fresh tabs",
"User profiles can add requester context, giving coding agents better background for assigned Issues",
"Workspace settings now have a dedicated GitHub page, and regular members can see connected GitHub installations without admin controls",
],
improvements: [
"New users are guided to connect a runtime instead of receiving starter content that may not match their workspace",
"Runtime pages are quieter, and desktop keeps the local machine visible after stopping the local service",
"Issue breadcrumbs show the Project segment when an Issue belongs to a Project",
"HTML previews and attachment previews have roomier, more predictable layouts",
"Squad pages show fuller loading states and use a clearer archive confirmation dialog",
"Agents now receive parent and sub-issue handoff guidance before running assigned work",
],
fixes: [
"List editing exits cleanly from an empty top-level item when pressing Enter",
"The installer falls back to release binaries when Homebrew setup fails and reports clearer diagnostics",
"Retrying an execution log row now reruns the agent that handled that row",
"Chat and task-message loading ignore temporary IDs instead of calling invalid task routes",
"OpenCode-backed daemon runs no longer enter invisible interactive question prompts",
"Gemini runtimes use the correct official icon",
],
},
{
version: "0.3.3",
date: "2026-05-19",

View File

@@ -284,35 +284,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.4",
date: "2026-05-20",
title: "自动任务项目归属、智能体思考设置与更稳的桌面端",
changes: [],
features: [
"自动任务现在可以通过小队分配工作,并把创建的 Issue 直接归入指定项目",
"智能体设置新增 Claude 和 Codex 的思考强度控制,并可在详情面板里直接调整",
"桌面端标签页可以固定,重要页面会留在左侧,打开新内容时不打断原页面",
"用户资料可以补充请求者背景,让代码智能体在处理 Issue 时更理解上下文",
"工作区设置新增 GitHub 专页,普通成员也能查看已连接的 GitHub 安装信息",
],
improvements: [
"新用户引导会优先创建连接运行环境的下一步,不再生成不合适的示例内容",
"运行环境页面减少重复信息,桌面端停止本机服务后仍能看到本机行并重新启动",
"Issue 面包屑会显示所属项目,查看来源更清楚",
"HTML 预览和附件预览拥有更合适的默认尺寸,查看内容更自然",
"小队列表加载状态更完整,归档小队时会使用更清晰的确认弹窗",
"智能体运行前会收到父 Issue / 子 Issue 协作规则,完成子任务后的回传更稳定",
],
fixes: [
"在空的顶层列表项按 Enter 时,编辑器可以正常退出列表",
"安装脚本在 Homebrew 失败时会自动改用发行版文件,并显示更清楚的诊断信息",
"从执行记录重试时,会重新唤起当时处理该记录的智能体",
"聊天和任务消息加载会跳过临时 ID避免访问无效任务",
"OpenCode 运行环境不再进入看不见的交互提问流程",
"Gemini 运行环境使用正确的官方图标",
],
},
{
version: "0.3.3",
date: "2026-05-19",

View File

@@ -15,7 +15,6 @@ export const mockUser: User = {
// field shipped — migration 054 backfills 'skipped_legacy'.
starter_content_state: "skipped_legacy",
language: null,
timezone: null,
profile_description: "",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",

View File

@@ -1,374 +0,0 @@
# Timezone 架构重构 — Scheduling / Viewing 两层模型
> Status: Implemented
> Last updated: 2026-05-20
## TL;DR
- **问题**:当前代码里 timezone 被三种语义混用,导致 workspace usage 页 picker 在 #2822 review 中被移除(前后端 tz 不一致会把跨 UTC 午夜的行算到错的 calendar week同时 runtime detail 页的 timezone editor 又承担了"既是物理 tz 又是报表 tz"的双重职责。
- **方案**:把 timezone 收敛成两个独立的 product 概念——**Scheduling**trigger 规则里写的"9 点"是哪个 9 点,由 `autopilot_trigger.timezone` 承载)和 **Viewing**(用户报表 tz由新字段 `user.timezone` 承载)。原先混在 `runtime.timezone` 上的"物理位置"语义Operational经盘查无真实消费者整列移除。
- **数据层**:把 `task_usage_daily` (per-runtime, 物化在 runtime tz) 和 `task_usage_dashboard_daily` (workspace 级, 物化在 UTC) **合并成一张 `task_usage_hourly` (UTC, hourly grain)**,所有报表查询按调用方 tz 在查询时切日界。
- **新增字段**`user.timezone`(默认 = browser detected可在 Preferences 覆盖)。
- **不引入** `workspace.timezone`——viewing tz 是查看者属性,不是 workspace 属性。
- **性能**hourly rollup 在密集工况16 active hours/day下单 ws 90d 窗口 ~15k 行、~15ms和现有 daily rollup 同档。
- **副产品**Migration 082 的"改 runtime tz → 重灌整张 rollup"逻辑可以删除;跨 region 团队自动支持各看各的"今天";未来要做 hourly heatmap / 时段分析无需再动 schema。
---
## 1. 背景
### 1.1 现状盘点
代码里"timezone"出现在四个地方:
| # | 位置 | 字段 | 实际语义 |
|---|---|---|---|
| 1 | `agent_runtime.timezone` | TEXT, daemon 探测或 UI 覆盖 | 报表 + 物理位置(混淆) |
| 2 | `autopilot_trigger.timezone` | TEXT, 用户写规则时选 | Scheduling正确 |
| 3 | Workspace Usage 页面 | 无字段,曾在前端用 `useState(browserTimezone())` | Viewing#2822 删除) |
| 4 | 各种 list / log 时间戳显示 | 浏览器 tz | Viewing隐式 |
### 1.2 问题
**问题 A — Runtime tz 同时承担两个不同的角色:**
`runtime.timezone` 在 migration 082 之后决定了 `task_usage_daily.bucket_date` 的物化口径,等于"报表 tz";同时 daemon 启动时 `detectLocalTimezone()` 写入这个字段,又当成"机器物理 tz"用。结果:
- 改这个字段会触发整张 rollup 重新物化migration 082 backfill 逻辑),代价不小。
- 一个 SF 的 dev 把 daemon 跑在 PST 的机器上,但 PM 在上海希望按 CST 出报表——这一个字段没法同时满足两个需求。
- daemon 自动探测的"客观真值"和用户手动想换的"我想看的报表 tz"被同一个 PATCH 接口覆盖,互相打架。
**问题 B — Workspace usage 页面没有正确的"报表 tz"概念:**
PR #2822 删除了 workspace usage 页的 TimezonePicker原因是
> 后端 dashboard rollup 把数据按 UTC `bucket_date` 聚合,但前端却驱动 Weekly 边界用用户在 picker 里选的 tz。靠近 UTC 午夜的行会被放进错的 calendar week。Lock workspace Weekly to UTC and remove the timezone picker。
这个修复是对的——前后端 tz 不一致就是 bug。但它**没解决根本问题**:用户确实需要按自己的 tz 看 workspace 报表,只是当前数据层没法支持。
**问题 C — Viewing tz 没有持久化:**
即使 picker 还在,它也只是 `useState(browserTimezone())`——刷新页面、换设备、跨 session 都会丢。用户每次都得手动切。
**问题 D — 没有"跨 region 团队"的支持位:**
把"报表 tz"放在 workspace 上是常见的诱惑,但 workspace 里两个成员一个在 SF 一个在 Beijing他们想看到的"今天"本来就不同。任何"workspace 级 tz 设置"都强制其中一个人看错位的报表。
### 1.3 目标
1. **架构上清晰**:每个 timezone 字段只回答一个问题。
2. **性能上不退步**:所有现有报表查询保持 <15ms 量级。
3. **正确性优先**:前后端 tz 物化口径必须一致,没有"前端切了但后端没跟"的 UI 谎言。
4. **跨 region 友好**:同一 workspace 不同成员可以各看各的"今天"。
---
## 2. 两个 timezone 概念
| 概念 | 在回答什么 | 谁是真值 | 承载字段 |
|---|---|---|---|
| **Scheduling** | "9 点跑"的 9 点是哪个 9 点 | 用户写规则那一刻的意图 | `autopilot_trigger.timezone` |
| **Viewing** | 我想看的"今天"是哪个日历日 | 当前查看者的偏好 | `user.timezone`(新增) |
**关键论断**:之前代码把"物理位置"和"报表口径"混在 `runtime.timezone` 一个字段上。重构后:
- Scheduling 不动,`autopilot_trigger.timezone` 已经正确。
- Viewing 由新字段 `user.timezone` 承载。
- 数据层不再按任何固定 tz 物化 bucket而是以 UTC 为唯一存储口径,所有报表查询在 read time 按调用方传入的 tz 切日界。
- `runtime.timezone` 整列删除——见 §2.1。
### 2.1 为什么不要 Operational 层
最初设计有第三个概念 **Operational**(机器物理在哪)。落地盘查后砍掉,两条理由:
**理由一 —— 就算需要 operational tz`runtime` 也是错的层级。** Operational tz 是**物理机器**的属性,不是 runtime 的属性。同一台机器可以跑多个 runtime它们共用同一个 OS 时钟operational tz 必然相同。把 tz 放在 `agent_runtime` 上,等于把一个 machine 级事实复制到同机每一行 runtime——天然的冗余与 drift 风险(同机两个 runtime 的 tz 被改得不一致是无意义的非法状态)。要建模 operational tz正确归属是 machine 层;而当前 schema 里根本没有 machine 实体,强行放 runtime 层只是把错误固化。
**理由二 —— 它的消费者都不需要 operational 语义。** `runtime.timezone` 今天承担"既是物理 tz 又是报表 tz"的双重职责,但盘查后没有一个读取者真正要"机器物理 tz"
- runtime detail 页的 Daily / Weekly 趋势图、KPI 卡片,通过 `task_usage_daily` 的物化口径间接吃这个 tz——这是**报表口径**语义,不是 operational。而且这些成本/token 数字要和 workspace dashboard 跨页对账dashboard 下挂多 runtime、多时区根本不存在"workspace 的 operational tz",可对账量只能统一走 Viewing tz。
- hour-of-day heatmap`GetRuntimeUsageByHour` / `GetRuntimeTaskActivity`)看似要"机器作息"属性,但若只让它一个图表走 operational用户在同一张卡里切 "Daily" ↔ "Heatmap" 会看到同一个"昨天"两个数。它也只能跟 Viewing tz。
autopilot 调度走 `trigger.timezone` 不碰它daemon 要时钟直接读 OS clock`TimezoneEditor` 只是编辑它自己。换句话说,凡是真读它的地方都应当是 Viewing tz——operational 语义在整个系统里没有一个真实需求点。
结论Operational 作为服务端持久化、用户可编辑的字段没有立足点。机器有物理时钟这个**事实**永远存在,但那是 daemon 进程内部的事,不必上 server。`runtime.timezone` 整列由 migration 104 删除。
代价已知且接受:跨 region 团队看一台 SF runtime 的 hour-of-day heatmap 时,按查看者自己的 tz如 Asia/Shanghai显示活跃时段而非机器本地的 9-to-5。对单 region 团队零影响。
---
## 3. 字段定义与 UI 文案
### 3.1 `runtime.timezone` — 已移除
由 migration `104_drop_runtime_timezone` 删除整列。daemon 注册不再上报 host tz`detectLocalTimezone()` 删除),`PATCH /api/runtimes/:id` 不再接受 `timezone`(只剩 `visibility`Runtime Detail 页的 timezone editor 删除。理由见 §2.1。
### 3.2 `autopilot_trigger.timezone` — 不动
已经正确。
### 3.3 `user.timezone` — 新增 Viewing 字段
实现见 migration `100_user_timezone`。表名是 `"user"`(单数、保留字需加引号):
```sql
ALTER TABLE "user"
ADD COLUMN timezone TEXT NULL;
COMMENT ON COLUMN "user".timezone IS
'User-preferred IANA timezone for report rendering (Viewing tz). '
'NULL means "use the browser-detected tz at render time". Affects '
'dashboards, charts, and any "today" label shown to this user. Does '
'not affect data materialisation — all rollups remain in UTC.';
```
`NULL` 是默认值——前端在 NULL 时 fallback 到 `browserTimezone()`。这样新用户零配置就有合理行为。
UI
- **Settings → Preferences → Timezone**dropdown可选 `(browser)` 或具体 IANA name。
- Hint`"Used for dashboards, charts, and any 'today' label shown to you. Other users in your workspaces will see their own timezone."`
### 3.4 不引入 `workspace.timezone`
理由见 §1.2 问题 D。如果未来真有"workspace 默认报表 tz"的需求(例如新成员加入时给一个建议默认值),可以在那时再加,与本 RFC 兼容——`user.timezone` 可作为 `workspace.timezone` 的 override。
### 3.5 Viewing tz 如何到达后端
报表 handler 通过 `Handler.resolveViewingTZ(r)` 解析当前请求该用哪个 tz 渲染,优先级:
1. `?tz=` query param —— 浏览器端 `useViewingTimezone()` 解析后随每个报表请求显式带上。
2. 已认证用户的 `user.timezone`query param 缺失时的 cold fallback会多查一次 `GetUser`)。
3. `"UTC"` —— 兜底。
非法 IANA 名直接跳过该级、不报错tz 是显示问题)。浏览器走 (1) 显式 query param 这条热路径,旧客户端 / API client 漏传时由 (2) 服务端读 `user.timezone` 兜底。Handler 拿到 tz 后用 `parseSinceParamInTZ``days=N` 折算成"查看者本地第 N 天零点"对应的 UTC 瞬间,再连同 `@tz` 一起传给 SQL。
---
## 4. 数据层设计
### 4.1 新表 `task_usage_hourly`
实现见 migration `101_task_usage_hourly_schema`(建表):
```sql
CREATE TABLE task_usage_hourly (
bucket_hour TIMESTAMPTZ NOT NULL, -- UTC, truncated to hour boundary
workspace_id UUID NOT NULL,
runtime_id UUID NOT NULL,
agent_id UUID NOT NULL,
project_id UUID, -- nullable
provider TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens BIGINT NOT NULL DEFAULT 0,
output_tokens BIGINT NOT NULL DEFAULT 0,
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
cache_write_tokens BIGINT NOT NULL DEFAULT 0,
task_count BIGINT NOT NULL DEFAULT 0, -- COUNT(DISTINCT task_id)
event_count BIGINT NOT NULL DEFAULT 0, -- COUNT(*) of task_usage rows
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_task_usage_hourly_key
UNIQUE NULLS NOT DISTINCT
(bucket_hour, workspace_id, runtime_id, agent_id, project_id, provider, model)
);
CREATE INDEX idx_task_usage_hourly_workspace_time
ON task_usage_hourly (workspace_id, bucket_hour DESC);
CREATE INDEX idx_task_usage_hourly_runtime_time
ON task_usage_hourly (runtime_id, bucket_hour DESC);
CREATE INDEX idx_task_usage_hourly_workspace_agent_time
ON task_usage_hourly (workspace_id, agent_id, bucket_hour DESC);
CREATE INDEX idx_task_usage_hourly_workspace_project_time
ON task_usage_hourly (workspace_id, project_id, bucket_hour DESC)
WHERE project_id IS NOT NULL;
```
**关于字段的几个落地决定**
- **没有 `cost_micros` 列**。成本不在数据层物化——`task_usage_hourly` 只存 token 计数PK 里带 `provider`+`model`,客户端按 per-model 定价表算成本。这样定价表更新无需重灌 rollup。
- **`task_count``event_count` 两个计数**`task_count``COUNT(DISTINCT task_id)``event_count``COUNT(*)`(同一 task 多次 usage 事件)。注意 task 跨多个 hour bucket 时 `task_count` 会按小时重复计——面向用户的"任务数"列优先用 `agent_task_queue` 派生的查询(见 §4.2hourly 表的 `task_count` 仅作信息参考。
- **`runtime_id``NOT NULL`**`agent_task_queue.runtime_id` 本身带 `NOT NULL` 约束migration 004所有建队列的写入路径含 quick-create都会带上 runtime所以 rollup 永远不会产生 no-runtime 的 bucket。`project_id` 可空是因为任务确实可以不挂 project。
migration 101 同时建了两张配套表:
- `task_usage_hourly_rollup_state` —— 单行 watermark 状态表(与 073/084 的 rollup_state 同形)。
- `task_usage_hourly_dirty` —— 失效队列,承载 `updated_at` watermark 看不到的失效(`task_usage` 的 DELETE、级联 DELETE、`issue.project_id` / `agent_task_queue.runtime_id` 改动导致的重新归属)。**必须配 TTL**,见 §4.4。
**这一张表替换两张现有表**
- `task_usage_daily` (migration 073, 082) — 含 runtime_id物化在 runtime tz
- `task_usage_dashboard_daily` (migration 084) — 含 agent_id/project_id物化在 UTC
合并后 PK 同时包含 runtime / agent / project 三个维度,可以从同一张表派生出所有现有视图。
### 4.2 查询模式
Token 类报表查询从 `task_usage_hourly` 派生,按调用方传入的 `@tz` 在查询时折算日界。**成本不在 SQL 里算**——查询只 `SUM` token 列并保留 `model` 维度,成本由客户端按 per-model 定价表折算(所以按日期分组的查询会保留 `model`,按 agent 分组的也是)。
```sql
-- Workspace dashboard 趋势图 ListDashboardUsageDaily按 viewer tz 切日,保留 model
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
model,
SUM(input_tokens)::bigint AS input_tokens,
SUM(output_tokens)::bigint AS output_tokens,
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
SUM(task_count)::int AS task_count
FROM task_usage_hourly
WHERE workspace_id = $1
AND bucket_hour >= @since::timestamptz
AND (@project_id::uuid IS NULL OR project_id = @project_id)
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), model
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, model;
-- Runtime detail 趋势图 ListRuntimeUsage按 viewer tz 切日tz 来自 user 不是 runtime
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
provider, model,
SUM(input_tokens)::bigint AS input_tokens,
...
FROM task_usage_hourly
WHERE runtime_id = $1
AND bucket_hour >= @since::timestamptz
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), provider, model
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, provider, model;
-- Per-agent 视图 ListDashboardUsageByAgent / ListRuntimeUsageByAgent
-- 不按日期分组 → 不需要 @tz只用 @since 截断(@since 已是 viewer tz 折算后的 UTC 瞬间)。
SELECT agent_id, model,
SUM(input_tokens)::bigint AS input_tokens,
...
FROM task_usage_hourly
WHERE workspace_id = $1
AND bucket_hour >= @since::timestamptz
GROUP BY agent_id, model
ORDER BY agent_id, model;
```
**两类查询不走 `task_usage_hourly`**
- **Time / Tasks 指标**dashboard 的"时长 / 任务数"标签页)由独立查询 `ListDashboardRunTimeDaily` / `ListDashboardAgentRunTime` 直接打 `agent_task_queue`,按 `completed_at AT TIME ZONE @tz` 切日——任务时长来自队列的 `started_at`/`completed_at`,不是 token rollup 能表达的。它们同样吃 `@tz`,保证 Tokens/Cost/Time/Tasks 四个标签页的日界一致。
- **Runtime hour-of-day Heatmap**`GetRuntimeUsageByHour` / `GetRuntimeTaskActivity`)仍直接扫原始 `task_usage` / `agent_task_queue`,按 **viewer tz**`resolveViewingTZ` 解析出的 `@tz`)做 `EXTRACT(HOUR FROM ... AT TIME ZONE @tz)`。Heatmap 窗口小(单 runtime、近 30/90draw 扫描足够快,没有必要从 hourly 表派生。
### 4.3 性能预估
单 workspace 90d 窗口的 `task_usage_hourly` 行数:
| 工况 | 行数估算 | 趋势图查询代价 |
|---|---|---|
| 小5 agent × 2 model × 2 active hour × 90d | ~1.8k | <5ms |
| 中5 agent × 2 model × 8 active hour × 90d | ~7.2k | <10ms |
| 大5 agent × 2 model × 16 active hour × 90d | ~14.4k | ~15ms |
| 巨大20 agent × 5 model × 16 active hour × 90d | ~144k | ~50ms |
和现有 daily rollup 在同一档。Leaderboard / per-agent / per-project 视图同样指标。
### 4.4 Rollup worker 改造
现有两张 rollup 表的写入逻辑合并成一条管线,实现见 migration `102_task_usage_hourly_pipeline`(触发器 + 窗口函数 + 失效队列 TTL + pg_cron 调度):
- 源数据扫描不变(仍然扫 `task_usage` 增量 + 失效队列)。`bucket_hour``task_usage_hour_bucket(tu.created_at)`UTC 整点截断)。
- Upsert 目标从两张 daily 表改为一张 `task_usage_hourly`
- 失效队列维度由 `(bucket_date, …)` 改为 `(bucket_hour, …)``task_usage_hourly_dirty`),由 `task_usage` / `agent_task_queue` / `issue` 上的触发器写入。**必须配 TTL保留 7 天)**否则脏行在密集工况下无界增长——这是整个设计最容易漏的正确性要求hourly 粒度把脏面比 daily 放大了 ~24×
- 调度入口 `rollup_task_usage_hourly()` 由 pg_cron 周期触发:取 advisory lock → 从 `task_usage_hourly_rollup_state` 读 watermark → 调 `rollup_task_usage_hourly_window(from, to)` 重算脏 bucket → 推进 watermark → 释放锁后跑 `prune_task_usage_hourly_dirty()`。单 tick 窗口上限 1 天watermark 落后时分多次 tick 追平,不会一条语句锁表重算多周。
源表扫描是 worker 的主要开销,目标表换粒度只让单 tick 多几十 ms upsert不会成倍增长。
### 4.5 Migration 082 的副作用消除
当前 `runtime.timezone` 的 PATCH 处理migration 082 + 现有 handler会触发该 runtime 的整张 `task_usage_daily` 重新物化——因为 `bucket_date` 含了 tz。
新方案下 `bucket_hour` 永远是 UTC**`runtime.timezone` 改变不再触发任何数据层操作**。改 tz 立即生效,零 backfill。这同时修掉了
- 改 tz 期间的 race condition旧 bucket 还没重灌完,新查询已经按新 tz 渲染)。
- daemon 第一次注册时探测到非 UTC 的 tz 但历史 rollup 还是 UTC 的尴尬过渡期。
---
## 5. UI / UX 影响
### 5.1 Runtime Detail 页
| 组件 | 重构前 tz 来源 | 重构后 tz 来源 |
|---|---|---|
| Daily / Weekly 趋势图 | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
| KPI 卡片 | `runtime.timezone`(隐式) | `user.timezone ?? browserTimezone()` |
| 日历活跃热力图 | `runtime.timezone` 锚点 + viewer-tz 数据(不一致 bug | `user.timezone ?? browserTimezone()`(锚点与数据统一) |
| Hour-of-day Heatmap | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
| Timezone editor | 写 `runtime.timezone` | **删除** |
**用户可感知的行为变化**
- Runtime Detail 页所有图表统一跟随 viewer 自己的 tz页面上不再有任何 runtime 级 tz 控件。
- 想换报表 tz 的用户去 Settings → Preferences 改一次,所有 workspace / runtime 的报表立刻全跟着变。
- 跨 region 团队hour-of-day heatmap 按查看者 tz 显示活跃时段(已知且接受的取舍,见 §2.1)。
### 5.2 Workspace Usage 页
恢复"按 viewing tz 渲染"的能力,但**不放页面级 picker**。理由:
- Picker 当年被加上去就是因为没有持久化的 viewing tz 概念。现在有了 `user.timezone`picker 的诉求被 Preferences 替代。
- 页面级 picker 容易让用户误以为"这是一个 view-state",但 viewing tz 是全应用属性,不是单页设置。
- 减少 UI 控件 = 减少认知负担。
`packages/views/dashboard/components/dashboard-page.tsx` 里的 `WEEK_TZ = "UTC"` 改成 `useViewingTimezone()`hook 见 `packages/views/common/use-viewing-timezone.ts`),相应的解释性注释删除。
### 5.3 Preferences 页
新增一个 Timezone setting和现有的语言 / 主题等并列。
---
## 6. 实施
> 产品尚未上线,无存量用户需保护,全部变更作为一组迁移一次性交付——旧的 daily 管线在同一分支里直接拆除,不保留共存期。
整套变更落在分支 `feat/timezone-architecture`migration 100104
| Migration | 内容 |
|---|---|
| `100_user_timezone` | 加 `"user".timezone`nullable |
| `101_task_usage_hourly_schema` | 建 `task_usage_hourly` + `task_usage_hourly_rollup_state` + `task_usage_hourly_dirty` + 索引 |
| `102_task_usage_hourly_pipeline` | 失效触发器、`rollup_task_usage_hourly_window` 窗口函数、`prune_task_usage_hourly_dirty()` 失效队列 TTL、带单日 cap 与 prune 的 `rollup_task_usage_hourly()` cron 入口、pg_cron 调度 |
| `103_drop_legacy_daily_rollups` | 拆掉 `task_usage_daily` / `task_usage_dashboard_daily` 两条旧管线表、函数、触发器、pg_cron 任务) |
| `104_drop_runtime_timezone` | 删除 `agent_runtime.timezone`Operational 层移除,见 §2.1 |
配套的代码侧改动:
- **数据回填**:一次性命令 `cmd/backfill_task_usage_hourly`,按 workspace 切片把历史 `task_usage` 灌进新表。旧的 `cmd/backfill_task_usage_daily` / `cmd/backfill_task_usage_dashboard_daily` 已删除。
- **查询切换**:后端所有报表查询迁到 `task_usage_hourly`(或 Time/Tasks 的 `agent_task_queue` 查询),统一接受 `@tz``UseDailyRollupForDashboard` / `UseDailyRollupForRuntimeUsage` 等 feature flag 与旧的 raw-scan / daily-rollup 双查询路径一并删除。
- **前端打通**`useViewingTimezone()` hook 解析 viewer tz报表组件随请求带 `?tz=``dashboard-page.tsx``WEEK_TZ = "UTC"` 改为 `useViewingTimezone()`,原 UTC-lock 解释性注释删除。
- **UI 文案**Preferences 新增 Timezone setting。Runtime Detail 页的 timezone editor 整体删除。
- **runtime tz 移除**`PATCH /api/runtimes/:id``timezone` 字段删除,该端点只剩 `visibility`daemon 注册不再上报 host tz`agent_runtime.timezone` 列由 migration 104 删除。
---
## 7. Open questions / Risks
### 7.1 Risks
- **Invalidation queue TTL 是必做**。如果忘记加,密集工况下 queue 会无界增长。
- **Hourly rollup backfill 期间的源表 read pressure**。按 workspace 切片、低峰期跑,预期 OK但需要提前给 DB 团队打招呼。
- **DST 当天的 23h/25h "日"**。`DATE(bucket_hour AT TIME ZONE @tz)` 会正确处理,但前端任何"一天 = 24 小时"的硬编码偏移逻辑要测一遍 DST 边界。
- **现有 `runtime.timezone` 的 PATCH endpoint 行为变了**。改完不再触发 backfill——这是好事但 API 文档和 changelog 要写清楚,避免下游集成误判。
### 7.2 Open question
- **Trigger 的 timezone 默认值**?目前用户必须手动选;可以默认 `user.timezone`,但用户写 trigger 时的 viewing tz 和 trigger 实际跑的 tz 是两件事,需要产品决策。
### 7.3 非目标
- **不做** workspace 级 tz 设置:跨 region 团队两个成员各自正确的"今天"不同workspace 级 tz 必让其中一方看错位报表。
- **不做** 预物化多 tz rollupIANA tz 列表有 ~600 个无法穷举、DST 需逐 tz 维护,而 hourly rollup 已经够快。
- **不做** issue / comment / inbox 等列表的 tz 切换——它们已经隐式用浏览器 tz本 RFC 不动。后续如果要让这些也跟 `user.timezone`,是独立的 follow-up。
---
## 8. 决策汇总
| 决策点 | 选择 |
|---|---|
| Timezone 概念分层 | Scheduling / Viewing 两层Operational 经盘查后移除) |
| `runtime.timezone` 角色 | ❌ 整列删除migration 104 |
| `user.timezone` 是否新增 | ✅ 新增nullable默认 fallback 到 browser |
| `workspace.timezone` 是否新增 | ❌ 不引入 |
| 数据层物化口径 | 统一 UTC, hourly grain |
| Rollup 表合并 | `task_usage_daily` + `task_usage_dashboard_daily``task_usage_hourly` |
| 报表 tz 切换粒度 | 全局 per-userPreferences不做 per-view picker |
| hour-of-day heatmap tz | viewer tz不再用机器物理 tz |

View File

@@ -50,6 +50,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
metadata: {},
owner_id: null,
visibility: "private",
timezone: "UTC",
last_seen_at: "2026-04-27T11:59:50Z",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",

View File

@@ -48,11 +48,10 @@ describe("ApiClient", () => {
await client.getAutopilot("ap-1");
await client.createAutopilot({
title: "Daily triage",
project_id: "project-1",
assignee_id: "agent-1",
execution_mode: "create_issue",
});
await client.updateAutopilot("ap-1", { status: "paused", project_id: null });
await client.updateAutopilot("ap-1", { status: "paused" });
await client.deleteAutopilot("ap-1");
await client.triggerAutopilot("ap-1");
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
@@ -79,7 +78,6 @@ describe("ApiClient", () => {
method: "POST",
body: JSON.stringify({
title: "Daily triage",
project_id: "project-1",
assignee_id: "agent-1",
execution_mode: "create_issue",
}),
@@ -87,7 +85,7 @@ describe("ApiClient", () => {
{
url: "https://api.example.test/api/autopilots/ap-1",
method: "PATCH",
body: JSON.stringify({ status: "paused", project_id: null }),
body: JSON.stringify({ status: "paused" }),
},
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
{ url: "https://api.example.test/api/autopilots/ap-1/trigger", method: "POST" },
@@ -152,91 +150,6 @@ describe("ApiClient", () => {
expect(headers["X-Client-OS"]).toBeUndefined();
});
it("uses the Cloud Runtime node API contract and forwards bootstrap PAT on create", async () => {
const node = {
id: "node-1",
owner_id: "user-1",
instance_id: "i-0123456789abcdef0",
region: "us-west-2",
instance_type: "g5.xlarge",
image_id: "ami-1",
subnet_id: "subnet-1",
name: "gpu-dev-01",
status: "launching",
tags: {},
metadata: {},
created_at: "2026-05-21T08:30:00Z",
updated_at: "2026-05-21T08:30:00Z",
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(node), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.listCloudRuntimeNodes({ limit: 20, offset: 5 });
await client.createCloudRuntimeNode(
{ instance_type: "g5.xlarge", name: "gpu-dev-01" },
{ userPAT: "mul_cloud_bootstrap_pat" },
);
const listCall = fetchMock.mock.calls[0]!;
const createCall = fetchMock.mock.calls[1]!;
expect(listCall[0]).toBe(
"https://api.example.test/api/cloud-runtime/nodes?limit=20&offset=5",
);
expect((listCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBeUndefined();
expect(createCall[0]).toBe(
"https://api.example.test/api/cloud-runtime/nodes",
);
expect(createCall[1]).toMatchObject({
method: "POST",
body: JSON.stringify({
instance_type: "g5.xlarge",
name: "gpu-dev-01",
}),
});
expect((createCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBe(
"mul_cloud_bootstrap_pat",
);
});
it("falls back when Cloud Runtime node responses drift", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify([{ id: 123 }]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 123 }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await expect(client.listCloudRuntimeNodes()).resolves.toEqual([]);
await expect(
client.createCloudRuntimeNode({ instance_type: "g5.xlarge" }),
).resolves.toMatchObject({ id: "", status: "" });
});
describe("getAttachment", () => {
it("returns the parsed attachment for a well-formed response", async () => {
vi.stubGlobal(

View File

@@ -101,12 +101,6 @@ import type {
SquadMemberStatusListResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import type {
CloudRuntimeNode,
CreateCloudRuntimeNodeOptions,
CreateCloudRuntimeNodeRequest,
ListCloudRuntimeNodesParams,
} from "../runtimes/cloud-runtime";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
@@ -117,8 +111,6 @@ import {
AttachmentResponseSchema,
ChildIssuesResponseSchema,
CommentsListSchema,
CloudRuntimeNodeListSchema,
CloudRuntimeNodeSchema,
CreateAgentFromTemplateResponseSchema,
DashboardAgentRunTimeListSchema,
DashboardRunTimeDailyListSchema,
@@ -127,8 +119,6 @@ import {
EMPTY_AGENT_TEMPLATE_DETAIL,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
EMPTY_ATTACHMENT,
EMPTY_CLOUD_RUNTIME_NODE,
EMPTY_CLOUD_RUNTIME_NODE_LIST,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_GROUPED_ISSUES_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
@@ -142,10 +132,6 @@ import {
ListWebhookDeliveriesResponseSchema,
OnboardingNoRuntimeBootstrapResponseSchema,
OnboardingRuntimeBootstrapResponseSchema,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
@@ -407,13 +393,10 @@ export class ApiClient {
completion_path?: OnboardingCompletionPath;
workspace_id?: string;
}): Promise<User> {
const raw = await this.fetch<unknown>("/api/me/onboarding/complete", {
return this.fetch("/api/me/onboarding/complete", {
method: "POST",
body: payload ? JSON.stringify(payload) : undefined,
});
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
endpoint: "POST /api/me/onboarding/complete",
});
}
async bootstrapOnboardingRuntime(payload: {
@@ -457,25 +440,19 @@ export class ApiClient {
email: string;
reason?: string;
}): Promise<User> {
const raw = await this.fetch<unknown>("/api/me/onboarding/cloud-waitlist", {
return this.fetch("/api/me/onboarding/cloud-waitlist", {
method: "POST",
body: JSON.stringify(payload),
});
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
endpoint: "POST /api/me/onboarding/cloud-waitlist",
});
}
async patchOnboarding(payload: {
questionnaire?: Record<string, unknown>;
}): Promise<User> {
const raw = await this.fetch<unknown>("/api/me/onboarding", {
return this.fetch("/api/me/onboarding", {
method: "PATCH",
body: JSON.stringify(payload),
});
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
endpoint: "PATCH /api/me/onboarding",
});
}
async updateMe(data: UpdateMeRequest): Promise<User> {
@@ -829,54 +806,13 @@ export class ApiClient {
return this.fetch(`/api/runtimes?${search}`);
}
async listCloudRuntimeNodes(
params?: ListCloudRuntimeNodesParams,
): Promise<CloudRuntimeNode[]> {
const search = new URLSearchParams();
if (params?.limit !== undefined) search.set("limit", String(params.limit));
if (params?.offset !== undefined) search.set("offset", String(params.offset));
const query = search.toString();
const raw = await this.fetch<unknown>(
`/api/cloud-runtime/nodes${query ? `?${query}` : ""}`,
);
return parseWithFallback(
raw,
CloudRuntimeNodeListSchema,
EMPTY_CLOUD_RUNTIME_NODE_LIST,
{ endpoint: "GET /api/cloud-runtime/nodes" },
);
}
async createCloudRuntimeNode(
data: CreateCloudRuntimeNodeRequest,
options?: CreateCloudRuntimeNodeOptions,
): Promise<CloudRuntimeNode> {
const extraHeaders: Record<string, string> = {
"Content-Type": "application/json",
};
const userPAT = options?.userPAT?.trim();
if (userPAT) extraHeaders["X-User-PAT"] = userPAT;
const res = await this.fetchRaw("/api/cloud-runtime/nodes", {
method: "POST",
body: JSON.stringify(data),
extraHeaders,
});
const raw = await res.json() as unknown;
return parseWithFallback(
raw,
CloudRuntimeNodeSchema,
EMPTY_CLOUD_RUNTIME_NODE,
{ endpoint: "POST /api/cloud-runtime/nodes" },
);
}
async deleteRuntime(runtimeId: string): Promise<void> {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
async updateRuntime(
runtimeId: string,
patch: { visibility?: "private" | "public" },
patch: { timezone?: string; visibility?: "private" | "public" },
): Promise<AgentRuntime> {
return this.fetch(`/api/runtimes/${runtimeId}`, {
method: "PATCH",
@@ -884,77 +820,32 @@ export class ApiClient {
});
}
async getRuntimeUsage(
runtimeId: string,
params?: { days?: number; tz?: string },
): Promise<RuntimeUsage[]> {
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
// `tz` drives the calendar-day boundary for the trend chart (Viewing
// layer). Caller-supplied; the backend falls back to user.timezone /
// UTC if omitted.
if (params?.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/usage?${search}`,
);
return parseWithFallback<RuntimeUsage[]>(raw, RuntimeUsageListSchema, [], {
endpoint: "GET /api/runtimes/:id/usage",
});
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
}
async getRuntimeTaskActivity(
runtimeId: string,
params?: { tz?: string },
): Promise<RuntimeHourlyActivity[]> {
// Hour-of-day heatmap follows the viewer's tz, like the other reports on
// this page. Pass the viewer's IANA zone so the server buckets correctly.
const search = new URLSearchParams();
if (params?.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/activity?${search}`,
);
return parseWithFallback<RuntimeHourlyActivity[]>(
raw,
RuntimeHourlyActivityListSchema,
[],
{ endpoint: "GET /api/runtimes/:id/activity" },
);
async getRuntimeTaskActivity(runtimeId: string): Promise<RuntimeHourlyActivity[]> {
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
}
async getRuntimeUsageByAgent(
runtimeId: string,
params?: { days?: number; tz?: string },
params?: { days?: number },
): Promise<RuntimeUsageByAgent[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
if (params?.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/usage/by-agent?${search}`,
);
return parseWithFallback<RuntimeUsageByAgent[]>(
raw,
RuntimeUsageByAgentListSchema,
[],
{ endpoint: "GET /api/runtimes/:id/usage/by-agent" },
);
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
}
async getRuntimeUsageByHour(
runtimeId: string,
params?: { days?: number; tz?: string },
params?: { days?: number },
): Promise<RuntimeUsageByHour[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
if (params?.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/usage/by-hour?${search}`,
);
return parseWithFallback<RuntimeUsageByHour[]>(
raw,
RuntimeUsageByHourListSchema,
[],
{ endpoint: "GET /api/runtimes/:id/usage/by-hour" },
);
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
}
// ---------------------------------------------------------------------------
@@ -965,12 +856,11 @@ export class ApiClient {
// ---------------------------------------------------------------------------
async getDashboardUsageDaily(
params: { days?: number; project_id?: string | null; tz?: string },
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
return parseWithFallback<DashboardUsageDaily[]>(
raw,
@@ -981,12 +871,11 @@ export class ApiClient {
}
async getDashboardUsageByAgent(
params: { days?: number; project_id?: string | null; tz?: string },
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageByAgent[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
return parseWithFallback<DashboardUsageByAgent[]>(
raw,
@@ -997,14 +886,11 @@ export class ApiClient {
}
async getDashboardAgentRunTime(
params: { days?: number; project_id?: string | null; tz?: string },
params: { days?: number; project_id?: string | null },
): Promise<DashboardAgentRunTime[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
// `tz` aligns the "last N days" cutoff with the viewer's calendar,
// matching the per-agent token card.
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
return parseWithFallback<DashboardAgentRunTime[]>(
raw,
@@ -1015,14 +901,11 @@ export class ApiClient {
}
async getDashboardRunTimeDaily(
params: { days?: number; project_id?: string | null; tz?: string },
params: { days?: number; project_id?: string | null },
): Promise<DashboardRunTimeDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
// `tz` cuts the day buckets in the viewer's calendar so Time / Tasks
// align with the Cost / Tokens charts.
if (params.tz) search.set("tz", params.tz);
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
return parseWithFallback<DashboardRunTimeDaily[]>(
raw,
@@ -1140,10 +1023,9 @@ export class ApiClient {
});
}
async rerunIssue(issueId: string, taskId?: string): Promise<AgentTask> {
async rerunIssue(issueId: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/rerun`, {
method: "POST",
body: JSON.stringify(taskId ? { task_id: taskId } : {}),
});
}

View File

@@ -1,17 +1,5 @@
import { describe, expect, it } from "vitest";
import {
DashboardAgentRunTimeListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
DuplicateIssueErrorBodySchema,
EMPTY_USER,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
UserSchema,
} from "./schemas";
import { parseWithFallback } from "./schema";
import { DuplicateIssueErrorBodySchema } from "./schemas";
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
// (typed as `unknown`) through this schema. Any future server drift that
@@ -61,106 +49,3 @@ describe("DuplicateIssueErrorBodySchema", () => {
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(true);
});
});
// `user.timezone` (Viewing tz) was added in the timezone-architecture RFC.
// A desktop build older than the server — or a server predating the
// `user.timezone` migration — will return a `/api/me` body with no
// `timezone` key. The schema must not fail closed on that: the field
// defaults to `null`, which the frontend resolves to the browser-detected
// tz at render time.
describe("UserSchema timezone drift", () => {
const base = {
id: "11111111-1111-1111-1111-111111111111",
name: "Ada",
email: "ada@example.com",
};
it("defaults timezone to null when the field is absent", () => {
const parsed = UserSchema.parse(base);
expect(parsed.timezone).toBe(null);
});
it("preserves an explicit IANA timezone", () => {
const parsed = UserSchema.parse({ ...base, timezone: "Asia/Tokyo" });
expect(parsed.timezone).toBe("Asia/Tokyo");
});
it("accepts an explicit null timezone", () => {
const parsed = UserSchema.parse({ ...base, timezone: null });
expect(parsed.timezone).toBe(null);
});
// Wrong-type drift: a future server bug sending `timezone` as a number
// must not throw into the UI. parseWithFallback degrades the whole user
// object to the explicit fallback (EMPTY_USER) so /api/me callers keep a
// valid shape instead of white-screening.
it("falls back to EMPTY_USER when timezone is the wrong type", () => {
const parsed = parseWithFallback(
{ ...base, timezone: 42 },
UserSchema,
EMPTY_USER,
{ endpoint: "GET /api/me" },
);
expect(parsed).toBe(EMPTY_USER);
});
});
// The workspace dashboard and runtime-detail pages were re-pointed at the
// unified `task_usage_hourly` rollup. Every numeric field drives chart /
// KPI math, and string keys (date / agent_id / model) bucket the series.
// The contract these schemas must hold: a row missing a field degrades
// that field to a sane default rather than dropping the WHOLE array to
// the `[]` fallback — one drifted row must not blank the entire chart.
describe("dashboard + runtime usage schema drift", () => {
it("coerces a missing numeric field to 0 instead of dropping the array", () => {
const parsed = DashboardUsageDailyListSchema.parse([
{ date: "2026-05-19", model: "claude-opus-4-7", input_tokens: 100 },
]);
expect(parsed).toHaveLength(1);
expect(parsed[0]?.output_tokens).toBe(0);
expect(parsed[0]?.cache_read_tokens).toBe(0);
expect(parsed[0]?.cache_write_tokens).toBe(0);
});
it("coerces a missing date key to \"\" so the rest of the series survives", () => {
const parsed = DashboardUsageDailyListSchema.parse([
{ model: "claude-opus-4-7", input_tokens: 5 },
]);
expect(parsed).toHaveLength(1);
expect(parsed[0]?.date).toBe("");
});
it("coerces a missing agent_id key to \"\" for the agent-runtime panel", () => {
const parsed = DashboardAgentRunTimeListSchema.parse([
{ total_seconds: 42, task_count: 3, failed_count: 0 },
]);
expect(parsed).toHaveLength(1);
expect(parsed[0]?.agent_id).toBe("");
});
it("coerces a missing agent_id key to \"\" for the usage-by-agent panel", () => {
const parsed = DashboardUsageByAgentListSchema.parse([
{ model: "claude-opus-4-7", input_tokens: 7 },
]);
expect(parsed[0]?.agent_id).toBe("");
});
it("coerces missing fields on every runtime usage schema", () => {
expect(RuntimeUsageListSchema.parse([{ date: "2026-05-19" }])[0]?.input_tokens).toBe(0);
expect(RuntimeHourlyActivityListSchema.parse([{ hour: 9 }])[0]?.count).toBe(0);
expect(RuntimeUsageByAgentListSchema.parse([{ model: "x" }])[0]?.agent_id).toBe("");
expect(RuntimeUsageByHourListSchema.parse([{ hour: 9 }])[0]?.model).toBe("");
});
it("rejects a non-array body so parseWithFallback can return its fallback", () => {
expect(DashboardUsageDailyListSchema.safeParse(null).success).toBe(false);
expect(RuntimeUsageListSchema.safeParse({ rows: [] }).success).toBe(false);
});
it("keeps unknown server-side fields via .loose()", () => {
const parsed = RuntimeUsageListSchema.parse([
{ date: "2026-05-19", region: "us-east" },
]);
expect((parsed[0] as Record<string, unknown>).region).toBe("us-east");
});
});

View File

@@ -12,7 +12,6 @@ import type {
User,
WebhookDelivery,
} from "../types";
import type { CloudRuntimeNode } from "../runtimes/cloud-runtime";
// ---------------------------------------------------------------------------
// Schemas for the highest-risk API endpoints — those whose responses drive
@@ -211,56 +210,19 @@ export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
issue_id: z.string(),
}).loose();
export const CloudRuntimeNodeSchema = z.object({
id: z.string(),
owner_id: z.string(),
instance_id: z.string(),
region: z.string(),
instance_type: z.string(),
image_id: z.string(),
subnet_id: z.string(),
name: z.string(),
status: z.string(),
tags: z.record(z.string(), z.string()).default({}),
metadata: z.record(z.string(), z.unknown()).default({}),
created_at: z.string(),
updated_at: z.string(),
}).loose();
export const CloudRuntimeNodeListSchema = z.array(CloudRuntimeNodeSchema);
export const EMPTY_CLOUD_RUNTIME_NODE_LIST: CloudRuntimeNode[] = [];
export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = {
id: "",
owner_id: "",
instance_id: "",
region: "",
instance_type: "",
image_id: "",
subnet_id: "",
name: "",
status: "",
tags: {},
metadata: {},
created_at: "",
updated_at: "",
};
// ---------------------------------------------------------------------------
// Workspace dashboard schemas
//
// The dashboard hits three independent rollup endpoints. Each returns a flat
// array, and every field is consumed by chart / KPI math — a missing number
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
// String fields default to "" (no enum narrowing) to survive future model /
// agent ID drift, and so a single null from tz-aware SQL bucketing fails
// only that row instead of dropping the whole array to the `[]` fallback.
// String fields stay lenient (no enum narrowing) to survive future model /
// agent ID drift.
// ---------------------------------------------------------------------------
const DashboardUsageDailySchema = z.object({
date: z.string().default(""),
model: z.string().default(""),
date: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
@@ -271,8 +233,8 @@ const DashboardUsageDailySchema = z.object({
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
const DashboardUsageByAgentSchema = z.object({
agent_id: z.string().default(""),
model: z.string().default(""),
agent_id: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
@@ -283,7 +245,7 @@ const DashboardUsageByAgentSchema = z.object({
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
const DashboardAgentRunTimeSchema = z.object({
agent_id: z.string().default(""),
agent_id: z.string(),
total_seconds: z.number().default(0),
task_count: z.number().default(0),
failed_count: z.number().default(0),
@@ -292,7 +254,7 @@ const DashboardAgentRunTimeSchema = z.object({
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
const DashboardRunTimeDailySchema = z.object({
date: z.string().default(""),
date: z.string(),
total_seconds: z.number().default(0),
task_count: z.number().default(0),
failed_count: z.number().default(0),
@@ -300,57 +262,6 @@ const DashboardRunTimeDailySchema = z.object({
export const DashboardRunTimeDailyListSchema = z.array(DashboardRunTimeDailySchema);
// ---------------------------------------------------------------------------
// Runtime usage schemas — the runtime-detail page's four usage endpoints
// (`/api/runtimes/:id/usage*`). Same leniency rules as the dashboard
// schemas above: numbers default to 0, strings to "", `.loose()` passes
// unknown fields.
// ---------------------------------------------------------------------------
const RuntimeUsageSchema = z.object({
runtime_id: z.string().default(""),
date: z.string().default(""),
provider: z.string().default(""),
model: z.string().default(""),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
}).loose();
export const RuntimeUsageListSchema = z.array(RuntimeUsageSchema);
const RuntimeHourlyActivitySchema = z.object({
hour: z.number().default(0),
count: z.number().default(0),
}).loose();
export const RuntimeHourlyActivityListSchema = z.array(RuntimeHourlyActivitySchema);
const RuntimeUsageByAgentSchema = z.object({
agent_id: z.string().default(""),
model: z.string().default(""),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const RuntimeUsageByAgentListSchema = z.array(RuntimeUsageByAgentSchema);
const RuntimeUsageByHourSchema = z.object({
hour: z.number().default(0),
model: z.string().default(""),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema);
// ---------------------------------------------------------------------------
// Agent template catalog — `/api/agent-templates*` and the
// create-from-template response. The desktop app's create-agent picker
@@ -595,7 +506,6 @@ export const UserSchema = z.object({
starter_content_state: z.string().nullable().default(null),
language: z.string().nullable().default(null),
profile_description: z.string().default(""),
timezone: z.string().nullable().default(null),
created_at: z.string().default(""),
updated_at: z.string().default(""),
}).loose();
@@ -610,7 +520,6 @@ export const EMPTY_USER: User = {
starter_content_state: null,
language: null,
profile_description: "",
timezone: null,
created_at: "",
updated_at: "",
};

View File

@@ -6,7 +6,6 @@ import { WSClient } from "./ws-client";
// upgrade URL construction, which is what carries client identity.
class FakeWebSocket {
static lastUrl: string | null = null;
static lastInstance: FakeWebSocket | null = null;
// Fields read by WSClient.connect()/disconnect(), all no-op here.
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
@@ -15,7 +14,6 @@ class FakeWebSocket {
readyState = 0;
constructor(url: string) {
FakeWebSocket.lastUrl = url;
FakeWebSocket.lastInstance = this;
}
close() {}
send() {}
@@ -24,7 +22,6 @@ class FakeWebSocket {
describe("WSClient", () => {
beforeEach(() => {
FakeWebSocket.lastUrl = null;
FakeWebSocket.lastInstance = null;
vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
});
@@ -72,59 +69,4 @@ describe("WSClient", () => {
expect(url.searchParams.has("client_version")).toBe(false);
expect(url.searchParams.has("client_os")).toBe(false);
});
it("truncates the logged payload when an unparseable frame is large", () => {
const logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const ws = new WSClient("ws://example.test/ws", { logger });
ws.connect();
const huge = "x".repeat(5000);
FakeWebSocket.lastInstance!.onmessage?.({ data: huge });
expect(logger.warn).toHaveBeenCalledTimes(1);
const [, summary] = logger.warn.mock.calls[0] as [string, string];
expect(summary.length).toBeLessThan(huge.length);
expect(summary).toContain("truncated");
expect(summary).toContain("5000");
expect(summary.startsWith("x".repeat(200))).toBe(true);
});
it("logs and skips malformed frames without breaking later messages", () => {
const logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const ws = new WSClient("ws://example.test/ws", { logger });
const handler = vi.fn();
ws.on("issue:updated", handler);
ws.connect();
expect(() => {
FakeWebSocket.lastInstance!.onmessage?.({ data: `{"type":"issue` });
}).not.toThrow();
FakeWebSocket.lastInstance!.onmessage?.({
data: JSON.stringify({
type: "issue:updated",
payload: { id: "issue-1" },
}),
});
expect(logger.warn).toHaveBeenCalledWith(
"ws: received unparseable message",
`{"type":"issue`,
);
expect(handler).toHaveBeenCalledWith(
{ id: "issue-1" },
undefined,
undefined,
);
});
});

View File

@@ -3,17 +3,6 @@ import { type Logger, noopLogger } from "../logger";
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
// Cap how much of an unparseable frame we put into the log. A malformed or
// rogue server can stream arbitrarily large garbage, and the warn handler may
// be a console / IPC bridge whose buffers we don't want to blow.
const UNPARSEABLE_LOG_MAX_CHARS = 200;
function summarizeUnparseable(data: unknown): string {
const text = typeof data === "string" ? data : String(data);
if (text.length <= UNPARSEABLE_LOG_MAX_CHARS) return text;
return `${text.slice(0, UNPARSEABLE_LOG_MAX_CHARS)}… (truncated, ${text.length} chars total)`;
}
/** Identifies the WS client to the server. Sent as `client_platform`,
* `client_version`, and `client_os` query parameters on the upgrade URL —
* browsers cannot set custom headers on WebSocket handshakes, so query
@@ -86,16 +75,7 @@ export class WSClient {
};
this.ws.onmessage = (event) => {
let msg: WSMessage;
try {
msg = JSON.parse(event.data as string) as WSMessage;
} catch {
this.logger.warn(
"ws: received unparseable message",
summarizeUnparseable(event.data),
);
return;
}
const msg = JSON.parse(event.data as string) as WSMessage;
if ((msg as any).type === "auth_ack") {
this.onAuthenticated();
return;

View File

@@ -1,19 +0,0 @@
import { describe, expect, it } from "vitest";
import { isTaskMessageTaskId, taskMessagesOptions } from "./queries";
describe("taskMessagesOptions", () => {
it("fetches task messages for persisted UUID task ids", () => {
const taskId = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc";
expect(isTaskMessageTaskId(taskId)).toBe(true);
expect(taskMessagesOptions(taskId).enabled).toBe(true);
});
it("does not fetch task messages for optimistic task ids", () => {
const taskId = "optimistic-optimistic-1778739487737";
expect(isTaskMessageTaskId(taskId)).toBe(false);
expect(taskMessagesOptions(taskId).enabled).toBe(false);
});
});

View File

@@ -21,12 +21,6 @@ export const chatKeys = {
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
};
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isTaskMessageTaskId(taskId: string | null | undefined): taskId is string {
return typeof taskId === "string" && UUID_PATTERN.test(taskId);
}
export function chatSessionsOptions(wsId: string) {
return queryOptions({
queryKey: chatKeys.sessions(wsId),
@@ -76,7 +70,7 @@ export function taskMessagesOptions(taskId: string) {
return queryOptions({
queryKey: chatKeys.taskMessages(taskId),
queryFn: () => api.listTaskMessages(taskId),
enabled: isTaskMessageTaskId(taskId),
enabled: !!taskId,
staleTime: Infinity,
});
}

View File

@@ -1,55 +1,45 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
// Workspace dashboard query options. All three endpoints share the same
// (wsId, days, projectId) key shape so workspace switching, time-range
// changes, and the project filter each invalidate the cache cleanly.
//
// The cache key includes `wsId` explicitly: TanStack Query already isolates
// per workspace via the key, but threading wsId into the queryFn lets
// callers fail fast (return [] on empty wsId) instead of issuing a request
// the server would reject.
//
// `projectId` is normalised to `null` (not undefined / "all") so the
// queryKey shape is stable across renders even when the dropdown sits on
// "all projects".
export const dashboardKeys = {
all: (wsId: string) => ["dashboard", wsId] as const,
daily: (
wsId: string,
days: number,
projectId: string | null,
tz: string,
) => [...dashboardKeys.all(wsId), "daily", days, projectId, tz] as const,
byAgent: (
wsId: string,
days: number,
projectId: string | null,
tz: string,
) => [...dashboardKeys.all(wsId), "by-agent", days, projectId, tz] as const,
agentRuntime: (
wsId: string,
days: number,
projectId: string | null,
tz: string,
) => [...dashboardKeys.all(wsId), "agent-runtime", days, projectId, tz] as const,
runTimeDaily: (
wsId: string,
days: number,
projectId: string | null,
tz: string,
) => [...dashboardKeys.all(wsId), "runtime-daily", days, projectId, tz] as const,
daily: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
byAgent: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
runTimeDaily: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "runtime-daily", days, projectId] as const,
};
// 5-min rollup cadence on the server, 60s background refetch on the client.
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
// driven on the server (5-min rollup cadence) and the dashboard isn't a
// real-time view, so background refetches every minute are plenty.
const STALE_TIME = 60 * 1000;
// `tz` participates in every dashboard key so a Preferences change
// repoints the cache. All four series — token rollups and the
// atq.completed_at-based run-time series — slice their day boundary in
// the viewer's tz, so the four dashboard tabs always agree.
export function dashboardUsageDailyOptions(
wsId: string,
days: number,
projectId: string | null,
tz: string,
) {
return queryOptions({
queryKey: dashboardKeys.daily(wsId, days, projectId, tz),
queryKey: dashboardKeys.daily(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageDaily({
days,
project_id: projectId ?? undefined,
tz,
}),
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
@@ -59,16 +49,11 @@ export function dashboardUsageByAgentOptions(
wsId: string,
days: number,
projectId: string | null,
tz: string,
) {
return queryOptions({
queryKey: dashboardKeys.byAgent(wsId, days, projectId, tz),
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageByAgent({
days,
project_id: projectId ?? undefined,
tz,
}),
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
@@ -78,16 +63,11 @@ export function dashboardAgentRunTimeOptions(
wsId: string,
days: number,
projectId: string | null,
tz: string,
) {
return queryOptions({
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId, tz),
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
queryFn: () =>
api.getDashboardAgentRunTime({
days,
project_id: projectId ?? undefined,
tz,
}),
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
@@ -97,16 +77,11 @@ export function dashboardRunTimeDailyOptions(
wsId: string,
days: number,
projectId: string | null,
tz: string,
) {
return queryOptions({
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId, tz),
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId),
queryFn: () =>
api.getDashboardRunTimeDaily({
days,
project_id: projectId ?? undefined,
tz,
}),
api.getDashboardRunTimeDaily({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});

View File

@@ -94,6 +94,7 @@ function makeRuntime(ownerId: string | null): RuntimeDevice {
metadata: {},
owner_id: ownerId,
visibility: "private",
timezone: "UTC",
last_seen_at: null,
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",

View File

@@ -1,7 +1,6 @@
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
export { useProjectDraftStore } from "./draft-store";
export { useProjectViewStore } from "./stores/view-store";
export {
projectResourceKeys,
projectResourcesOptions,

View File

@@ -1,96 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { useProjectViewStore } from "./view-store";
import { setCurrentWorkspace } from "../../platform/workspace-storage";
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
// can round-trip values.
beforeAll(() => {
if (typeof globalThis.localStorage?.clear !== "function") {
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (k) => values.get(k) ?? null,
key: (i) => Array.from(values.keys())[i] ?? null,
removeItem: (k) => { values.delete(k); },
setItem: (k, v) => { values.set(k, v); },
};
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
}
});
beforeEach(() => {
localStorage.clear();
useProjectViewStore.setState({ viewMode: "compact" });
setCurrentWorkspace(null, null);
});
afterEach(() => {
setCurrentWorkspace(null, null);
});
describe("useProjectViewStore", () => {
it("defaults to 'compact'", () => {
expect(useProjectViewStore.getState().viewMode).toBe("compact");
});
it("setViewMode mutates the store", () => {
useProjectViewStore.getState().setViewMode("comfortable");
expect(useProjectViewStore.getState().viewMode).toBe("comfortable");
});
it("partialize persists only viewMode under the workspace-namespaced key", async () => {
setCurrentWorkspace("acme", "ws_a");
await flush();
useProjectViewStore.getState().setViewMode("comfortable");
const raw = localStorage.getItem("multica_projects_view:acme");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.state).toEqual({ viewMode: "comfortable" });
});
it("rehydrates a different saved viewMode on workspace switch", async () => {
localStorage.setItem(
"multica_projects_view:acme",
JSON.stringify({ state: { viewMode: "comfortable" }, version: 0 }),
);
localStorage.setItem(
"multica_projects_view:beta",
JSON.stringify({ state: { viewMode: "compact" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useProjectViewStore.getState().viewMode).toBe("comfortable");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useProjectViewStore.getState().viewMode).toBe("compact");
});
it("resets to 'compact' when switching to a workspace with no persisted value", async () => {
localStorage.setItem(
"multica_projects_view:acme",
JSON.stringify({ state: { viewMode: "comfortable" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useProjectViewStore.getState().viewMode).toBe("comfortable");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useProjectViewStore.getState().viewMode).toBe("compact");
expect(localStorage.getItem("multica_projects_view:acme")).not.toBeNull();
});
});

View File

@@ -1,33 +0,0 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type ProjectViewMode = "compact" | "comfortable";
export interface ProjectViewState {
viewMode: ProjectViewMode;
setViewMode: (mode: ProjectViewMode) => void;
}
export const useProjectViewStore = create<ProjectViewState>()(
persist(
(set) => ({
viewMode: "compact",
setViewMode: (mode) => set({ viewMode: mode }),
}),
{
name: "multica_projects_view",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state) => ({ viewMode: state.viewMode }),
merge: (persisted, current) => {
if (!persisted) return { ...current, viewMode: "compact" };
return { ...current, ...(persisted as Partial<ProjectViewState>) };
},
}
)
);
registerForWorkspaceRehydration(() => useProjectViewStore.persist.rehydrate());

View File

@@ -1,90 +0,0 @@
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
export interface CloudRuntimeNode {
id: string;
owner_id: string;
instance_id: string;
region: string;
instance_type: string;
image_id: string;
subnet_id: string;
name: string;
status: string;
tags: Record<string, string>;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface ListCloudRuntimeNodesParams {
limit?: number;
offset?: number;
}
export interface CreateCloudRuntimeNodeRequest {
instance_type: string;
name?: string;
region?: string;
image_id?: string;
subnet_id?: string;
key_name?: string;
iam_instance_profile?: string;
disk_size_gb?: number;
tags?: Record<string, string>;
}
export interface CreateCloudRuntimeNodeOptions {
userPAT?: string;
}
export const cloudRuntimeKeys = {
all: (wsId: string) => ["cloud-runtime", wsId] as const,
nodes: (wsId: string) => [...cloudRuntimeKeys.all(wsId), "nodes"] as const,
};
const PENDING_NODE_STATUSES = new Set([
"launching",
"pending",
"starting",
"stopping",
"rebooting",
"terminating",
]);
export function isCloudRuntimeNodePending(status: string): boolean {
return PENDING_NODE_STATUSES.has(status.toLowerCase());
}
export function cloudRuntimeNodeListOptions(
wsId: string,
params?: ListCloudRuntimeNodesParams,
) {
const limit = params?.limit ?? 20;
const offset = params?.offset ?? 0;
return queryOptions({
queryKey: [...cloudRuntimeKeys.nodes(wsId), { limit, offset }] as const,
queryFn: () => api.listCloudRuntimeNodes({ limit, offset }),
refetchInterval: (query) =>
query.state.data?.some((node) => isCloudRuntimeNodePending(node.status))
? 5000
: false,
staleTime: 15 * 1000,
});
}
export function useCreateCloudRuntimeNode(wsId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
data,
userPAT,
}: {
data: CreateCloudRuntimeNodeRequest;
userPAT?: string;
}) => api.createCloudRuntimeNode(data, { userPAT }),
onSettled: () => {
qc.invalidateQueries({ queryKey: cloudRuntimeKeys.all(wsId) });
},
});
}

View File

@@ -18,6 +18,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
metadata: {},
owner_id: null,
visibility: "private",
timezone: "UTC",
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",

View File

@@ -8,4 +8,3 @@ export * from "./derive-health";
export * from "./use-runtime-health";
export * from "./cli-version";
export * from "./custom-pricing-store";
export * from "./cloud-runtime";

View File

@@ -12,8 +12,12 @@ export function useDeleteRuntime(wsId: string) {
});
}
// useUpdateRuntime patches editable fields on a runtime (visibility).
// Invalidates the runtime list so the picker disabled-state recomputes.
// useUpdateRuntime patches editable fields on a runtime (timezone, visibility).
// Invalidates the runtime list AND any keys downstream of the updated runtime
// — usage queries are bucketed by tz on the server, so a tz change must blow
// away cached usage rows or the chart would lie for one polling cycle. A
// visibility change only needs the runtime list to refetch so the picker
// disabled-state recomputes.
export function useUpdateRuntime(wsId: string) {
const qc = useQueryClient();
return useMutation({
@@ -22,10 +26,23 @@ export function useUpdateRuntime(wsId: string) {
patch,
}: {
runtimeId: string;
patch: { visibility?: "private" | "public" };
patch: { timezone?: string; visibility?: "private" | "public" };
}) => api.updateRuntime(runtimeId, patch),
onSettled: () => {
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
if (vars && vars.patch.timezone !== undefined) {
// Usage query keys are not workspace-scoped; invalidate only this
// runtime's daily/by-agent/by-hour usage rows under the new tz buckets.
qc.invalidateQueries({
queryKey: ["runtimes", "usage", vars.runtimeId],
});
qc.invalidateQueries({
queryKey: ["runtimes", "usage", "by-agent", vars.runtimeId],
});
qc.invalidateQueries({
queryKey: ["runtimes", "usage", "by-hour", vars.runtimeId],
});
}
},
});
}

View File

@@ -5,45 +5,43 @@ export const runtimeKeys = {
all: (wsId: string) => ["runtimes", wsId] as const,
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
listMine: (wsId: string) => [...runtimeKeys.all(wsId), "list", "mine"] as const,
usage: (rid: string, days: number, tz: string) =>
["runtimes", "usage", rid, days, tz] as const,
usageByAgent: (rid: string, days: number, tz: string) =>
["runtimes", "usage", "by-agent", rid, days, tz] as const,
// by-hour now follows the viewer's tz, like the other reports.
usageByHour: (rid: string, days: number, tz: string) =>
["runtimes", "usage", "by-hour", rid, days, tz] as const,
usage: (rid: string, days: number) =>
["runtimes", "usage", rid, days] as const,
usageByAgent: (rid: string, days: number) =>
["runtimes", "usage", "by-agent", rid, days] as const,
usageByHour: (rid: string, days: number) =>
["runtimes", "usage", "by-hour", rid, days] as const,
latestVersion: () => ["runtimes", "latestVersion"] as const,
};
// `tz` is the viewer's IANA name — all reports follow the viewer's tz.
export function runtimeUsageOptions(
runtimeId: string,
days: number,
tz: string,
) {
// Per-runtime usage. Used by the list view (each row pulls its own activity
// sparkline + 30d cost) and by the detail page. TanStack Query naturally
// deduplicates concurrent calls for the same runtime, so multiple components
// observing the same runtimeId share one network request.
export function runtimeUsageOptions(runtimeId: string, days: number) {
return queryOptions({
queryKey: runtimeKeys.usage(runtimeId, days, tz),
queryFn: () => api.getRuntimeUsage(runtimeId, { days, tz }),
queryKey: runtimeKeys.usage(runtimeId, days),
queryFn: () => api.getRuntimeUsage(runtimeId, { days }),
staleTime: 60 * 1000,
});
}
export function runtimeUsageByAgentOptions(
runtimeId: string,
days: number,
tz: string,
) {
// Per-agent token totals for one runtime — drives the "Cost by agent" tab
// on the runtime detail page. Server-side aggregation keeps the response
// small (one row per agent) regardless of task volume.
export function runtimeUsageByAgentOptions(runtimeId: string, days: number) {
return queryOptions({
queryKey: runtimeKeys.usageByAgent(runtimeId, days, tz),
queryFn: () => api.getRuntimeUsageByAgent(runtimeId, { days, tz }),
queryKey: runtimeKeys.usageByAgent(runtimeId, days),
queryFn: () => api.getRuntimeUsageByAgent(runtimeId, { days }),
staleTime: 60 * 1000,
});
}
export function runtimeUsageByHourOptions(runtimeId: string, days: number, tz: string) {
// Hourly (0..23) token totals for one runtime — drives the "By hour" tab.
export function runtimeUsageByHourOptions(runtimeId: string, days: number) {
return queryOptions({
queryKey: runtimeKeys.usageByHour(runtimeId, days, tz),
queryFn: () => api.getRuntimeUsageByHour(runtimeId, { days, tz }),
queryKey: runtimeKeys.usageByHour(runtimeId, days),
queryFn: () => api.getRuntimeUsageByHour(runtimeId, { days }),
staleTime: 60 * 1000,
});
}

View File

@@ -26,6 +26,7 @@ export interface RuntimeDevice {
owner_id: string | null;
/** Defaults to "private" when the backend predates the visibility flag. */
visibility: RuntimeVisibility;
timezone: string;
last_seen_at: string | null;
created_at: string;
updated_at: string;
@@ -39,7 +40,6 @@ export type AgentRuntime = RuntimeDevice;
export type TaskFailureReason =
| "agent_error"
| "timeout"
| "codex_semantic_inactivity"
| "runtime_offline"
| "runtime_recovery"
| "manual";
@@ -130,17 +130,6 @@ export interface Agent {
status: AgentStatus;
max_concurrent_tasks: number;
model: string;
/**
* Runtime-native reasoning/effort token (e.g. Claude's
* `low|medium|high|xhigh|max`, Codex's
* `none|minimal|low|medium|high|xhigh`). Empty string means "no
* override": the backend omits the effort flag and the upstream CLI
* config / built-in default decides at run time. The picker is
* per-runtime per-model — the API never normalises across providers.
* Older backends omit this field entirely; treat undefined as ""
* (MUL-2339).
*/
thinking_level?: string;
owner_id: string | null;
skills: AgentSkillSummary[];
created_at: string;
@@ -174,8 +163,6 @@ export interface CreateAgentRequest {
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
model?: string;
/** Optional runtime-native reasoning/effort token. See `Agent.thinking_level`. */
thinking_level?: string;
/** Optional template slug used by the onboarding agent picker. Surfaced
* as the `template` property on the `agent_created` PostHog event. */
template?: string;
@@ -264,15 +251,6 @@ export interface UpdateAgentRequest {
status?: AgentStatus;
max_concurrent_tasks?: number;
model?: string;
/**
* Runtime-native reasoning/effort token. Tri-state semantics (MUL-2339):
* - field omitted → no change
* - "" → clear the override; backend omits the effort flag and the
* local CLI config / built-in default decides what the model runs at
* - non-empty → set; validated server-side against the target
* runtime's provider enum, rejected with 400 if not recognised
*/
thinking_level?: string;
}
// Skills
@@ -453,34 +431,6 @@ export interface RuntimeModel {
label: string;
provider?: string;
default?: boolean;
/**
* Per-model reasoning/effort catalog discovered by the daemon. Currently
* populated for claude and codex runtimes only; omitted (or undefined)
* for every other provider, which the UI treats as "no thinking-level
* picker for this model". See MUL-2339.
*/
thinking?: RuntimeModelThinking;
}
export interface RuntimeModelThinking {
/** Levels the user is allowed to pick for this model. */
supported_levels: RuntimeModelThinkingLevel[];
/** Informational: the level the upstream CLI documents as its built-in
* default when no `--effort` flag is passed. Surfaced by the daemon
* but not actively rendered today — Multica's empty `thinking_level`
* means "no override; let the local CLI config decide", which may
* itself differ from this value. */
default_level?: string;
}
export interface RuntimeModelThinkingLevel {
/** Runtime-native token passed to the CLI; never normalised. */
value: string;
/** Display label matching each CLI's own UI (`Low`, `Extra high`, …). */
label: string;
/** Optional helper copy lifted from upstream catalog
* (`codex debug models` emits one per level). */
description?: string;
}
export type RuntimeModelListStatus =

View File

@@ -155,8 +155,6 @@ export interface UpdateMeRequest {
language?: string;
/** Free-form self-description (max 2000 chars). Pass "" to clear. */
profile_description?: string;
/** IANA tz to pin; "" clears back to browser-tz; undefined leaves untouched. */
timezone?: string;
}
export interface CreateMemberRequest {

View File

@@ -28,7 +28,6 @@ export interface Autopilot {
workspace_id: string;
title: string;
description: string | null;
project_id?: string | null;
assignee_type: AutopilotAssigneeType;
assignee_id: string;
status: AutopilotStatus;
@@ -83,7 +82,6 @@ export interface AutopilotRun {
export interface CreateAutopilotRequest {
title: string;
description?: string;
project_id?: string | null;
// Optional on the wire — when omitted the server defaults to "agent" so
// older clients keep working.
assignee_type?: AutopilotAssigneeType;
@@ -95,7 +93,6 @@ export interface CreateAutopilotRequest {
export interface UpdateAutopilotRequest {
title?: string;
description?: string | null;
project_id?: string | null;
// Send `assignee_type` together with `assignee_id` whenever you change the
// assignee — the server requires both for a type swap.
assignee_type?: AutopilotAssigneeType;

View File

@@ -36,8 +36,6 @@ export type {
RuntimeUpdate,
RuntimeUpdateStatus,
RuntimeModel,
RuntimeModelThinking,
RuntimeModelThinkingLevel,
RuntimeModelListRequest,
RuntimeModelListStatus,
RuntimeModelsResult,

View File

@@ -55,8 +55,6 @@ export interface User {
* NOT NULL DEFAULT '' at the column level, empty when unset.
*/
profile_description: string;
/** Pinned IANA tz; null means "use browser-detected tz at render time". */
timezone: string | null;
created_at: string;
updated_at: string;
}

View File

@@ -43,7 +43,6 @@ import { ConcurrencyPicker } from "./inspector/concurrency-picker";
import { ModelPicker } from "./inspector/model-picker";
import { RuntimePicker } from "./inspector/runtime-picker";
import { SkillAttach } from "./inspector/skill-attach";
import { ThinkingPropRow } from "./inspector/thinking-prop-row";
import { VisibilityPicker } from "./inspector/visibility-picker";
interface InspectorProps {
@@ -131,14 +130,6 @@ export function AgentDetailInspector({
onChange={(m) => update({ model: m })}
/>
</PropRow>
<ThinkingPropRow
runtimeId={agent.runtime_id}
runtimeOnline={!!isOnline}
model={agent.model ?? ""}
value={agent.thinking_level ?? ""}
canEdit={canEdit}
onChange={(v) => update({ thinking_level: v })}
/>
<PropRow label={t(($) => $.inspector.prop_visibility)} interactive={false}>
<VisibilityPicker
value={agent.visibility}

View File

@@ -101,44 +101,11 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
const [confirmArchive, setConfirmArchive] = useState(false);
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
// Optimistic update: patch the matching agent in the cached list
// BEFORE the network round-trip so the inspector picker chips flip to
// the new value immediately on click. Without this, every inspector
// picker (thinking / visibility / concurrency / model / runtime) waits
// 0.5-2s for the API response + invalidate + refetch before the trigger
// updates — readable as obvious lag in the UI.
//
// On error we rollback only the fields THIS call wrote, leaving any
// other concurrently-mutated fields untouched, then invalidate so the
// cache converges with the server. A whole-list snapshot rollback
// would clobber a concurrent successful mutation if the failing call
// resolves last (e.g. flipping visibility then runtime simultaneously
// and only the visibility PATCH fails).
const queryKey = workspaceKeys.agents(wsId);
const prevAgents = qc.getQueryData<Agent[]>(queryKey);
const prevAgent = prevAgents?.find((a) => a.id === id);
const prevFields: Record<string, unknown> = {};
if (prevAgent) {
for (const key of Object.keys(data)) {
prevFields[key] = (prevAgent as unknown as Record<string, unknown>)[key];
}
}
qc.setQueryData<Agent[]>(queryKey, (old) =>
old?.map((a) => (a.id === id ? ({ ...a, ...data } as Agent) : a)),
);
try {
await api.updateAgent(id, data as UpdateAgentRequest);
qc.invalidateQueries({ queryKey });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success(t(($) => $.detail.agent_updated_toast));
} catch (e) {
if (prevAgent) {
qc.setQueryData<Agent[]>(queryKey, (old) =>
old?.map((a) =>
a.id === id ? ({ ...a, ...prevFields } as Agent) : a,
),
);
}
qc.invalidateQueries({ queryKey });
toast.error(e instanceof Error ? e.message : t(($) => $.detail.update_failed_toast));
throw e;
}

View File

@@ -87,6 +87,7 @@ function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
metadata: {},
owner_id: ME,
visibility: "private",
timezone: "UTC",
last_seen_at: "2026-04-27T11:59:50Z",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",

View File

@@ -145,21 +145,21 @@ export function ModelPicker({
// string actually ships to the agent.
tooltip={m.label !== m.id ? `${m.label} · ${m.id}` : m.id}
>
{/* PickerItem wraps children in a flex `<span>`. Putting a
`<div>` inside that <span> is block-in-inline (invalid
HTML5) and triggers the browser-default centering quirk
that pushes descendants off-axis (model IDs floated to the
center instead of left-aligning under their labels). Use
`<span block text-left>` to keep layout deterministic —
matches the fix already applied in thinking-picker.tsx. */}
<span className="block min-w-0 flex-1 text-left">
<span className="block truncate text-[13px] font-medium">{m.label}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{m.label}</span>
{m.default && (
<span className="shrink-0 rounded bg-primary/10 px-1 text-[10px] font-medium text-primary">
{t(($) => $.pickers.model_default_badge)}
</span>
)}
</div>
{m.label !== m.id && (
<span className="mt-0.5 block truncate font-mono text-[10px] leading-snug text-muted-foreground">
<div className="truncate font-mono text-[10px] text-muted-foreground">
{m.id}
</span>
</div>
)}
</span>
</div>
</PickerItem>
))}

View File

@@ -1,112 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { RuntimeModelThinkingLevel } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../../locales/en/common.json";
import enAgents from "../../../locales/en/agents.json";
import enIssues from "../../../locales/en/issues.json";
import { ThinkingPicker } from "./thinking-picker";
const TEST_RESOURCES = {
en: { common: enCommon, agents: enAgents, issues: enIssues },
};
const CODEX_LEVELS: RuntimeModelThinkingLevel[] = [
{ value: "minimal", label: "Minimal", description: "Fast, light reasoning" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
];
function renderPicker(props: Partial<React.ComponentProps<typeof ThinkingPicker>> = {}) {
const onChange = vi.fn();
const utils = render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ThinkingPicker
value=""
levels={CODEX_LEVELS}
canEdit
onChange={onChange}
{...props}
/>
</I18nProvider>,
);
return { ...utils, onChange };
}
describe("ThinkingPicker", () => {
beforeEach(() => {
cleanup();
});
afterEach(() => {
cleanup();
});
it('renders "Follow CLI config" when value is empty', () => {
renderPicker({ value: "" });
// The trigger and the tooltip both carry the label. Empty value means
// Multica omits --effort, so the local CLI's config decides the
// reasoning level — see thinking-prop-row.tsx for the contract.
expect(screen.getAllByText("Follow CLI config").length).toBeGreaterThan(0);
});
it("renders the matching level label when value is set", () => {
renderPicker({ value: "high" });
expect(screen.getAllByText("High").length).toBeGreaterThan(0);
});
it("renders the raw token when the saved value is no longer in the catalog", () => {
// Simulates a model swap that dropped the option the user previously
// picked — we still surface what's persisted so the user can clear it,
// rather than silently showing "Follow CLI config".
renderPicker({ value: "xhigh", levels: CODEX_LEVELS });
expect(screen.getAllByText("xhigh").length).toBeGreaterThan(0);
});
it("renders a static read-only display when canEdit=false and exposes no popover trigger", () => {
renderPicker({ value: "low", canEdit: false });
expect(screen.getByText("Low")).toBeInTheDocument();
expect(screen.queryByRole("button")).toBeNull();
});
it("calls onChange with the picked value and skips when the user re-picks the current value", () => {
const { onChange } = renderPicker({ value: "low" });
fireEvent.click(screen.getByRole("button"));
// Picking a new level fires onChange with the runtime-native value.
fireEvent.click(screen.getByText("High"));
expect(onChange).toHaveBeenCalledWith("high");
// Re-opening and clicking the already-selected value is a no-op so we
// don't enqueue a redundant PATCH. The trigger also reads "Low", so
// there are two matches in the DOM — target the listbox item by
// selecting the option button explicitly.
onChange.mockClear();
fireEvent.click(screen.getByRole("button"));
const lowOption = screen
.getAllByRole("button")
.find((b) => b.getAttribute("data-picker-item") !== null && b.textContent?.includes("Low"));
expect(lowOption).toBeDefined();
fireEvent.click(lowOption!);
expect(onChange).not.toHaveBeenCalled();
});
it("clears to empty string via the footer button when a value is set", () => {
const { onChange } = renderPicker({ value: "high" });
fireEvent.click(screen.getByRole("button"));
// Footer copy resolves through i18n — match a substring so we don't
// pin to the exact translated wording.
const clearButton = screen.getByTitle(/Clear the override/i);
fireEvent.click(clearButton);
expect(onChange).toHaveBeenCalledWith("");
});
it("does not render the clear button when value is already empty", () => {
renderPicker({ value: "" });
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTitle(/Clear and fall back/i)).toBeNull();
});
});

View File

@@ -1,134 +0,0 @@
"use client";
import { useState } from "react";
import type { RuntimeModelThinkingLevel } from "@multica/core/types";
import {
PickerItem,
PropertyPicker,
} from "../../../issues/components/pickers";
import { CHIP_CLASS } from "./chip";
import { useT } from "../../../i18n";
/**
* Per-agent reasoning/effort picker (MUL-2339). Renders only when the
* current model exposes a non-empty `supported_levels` set — Claude and
* Codex today; every other provider gets nothing. The catalog is daemon-
* discovered, so the value/label pairs match each CLI's own UI (`Low`,
* `Extra high`, …) verbatim; never normalised across providers.
*
* Empty string is the "no override" sentinel: the backend omits the
* effort flag entirely and the upstream CLI's own config / built-in
* default decides what the model runs at. We render that state as
* "Follow CLI config" rather than singling out one level as the
* factory default, because the actual default at runtime is owned by
* the user's local CLI install, not by Multica's catalog.
*/
export function ThinkingPicker({
value,
levels,
canEdit = true,
onChange,
}: {
/** Persisted thinking_level — "" means "follow local CLI config". */
value: string;
/** Supported levels for the current (runtime, model) pair. Usually
* non-empty when the row is shown, but the stale-orphan clear path
* in ThinkingPropRow mounts the picker with an empty list plus a
* persisted value so the user can see and clear the dangling token. */
levels: RuntimeModelThinkingLevel[];
/** When false, render a static read-only display and skip the popover. */
canEdit?: boolean;
onChange: (next: string) => Promise<void> | void;
}) {
const { t } = useT("agents");
const [open, setOpen] = useState(false);
const selected = value ? levels.find((l) => l.value === value) : undefined;
// Unknown-but-set value (model swap that dropped the option, CLI upgrade
// that trimmed the catalog): show the raw token so the user can see what
// is actually persisted and clear it, rather than silently labelling it
// "Default" when the backend would still send the stale value.
const triggerLabel = selected
? selected.label
: value || t(($) => $.pickers.thinking_default);
const triggerTitle = t(($) => $.pickers.thinking_tooltip, {
value: triggerLabel,
});
const select = async (next: string) => {
setOpen(false);
if (next !== value) await onChange(next);
};
if (!canEdit) {
return (
<span
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
title={triggerTitle}
>
{triggerLabel}
</span>
);
}
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-auto min-w-[14rem] max-w-md"
align="start"
tooltip={triggerTitle}
triggerRender={
<button
type="button"
className={CHIP_CLASS}
aria-label={triggerTitle}
/>
}
trigger={
<span className="min-w-0 truncate font-mono text-[11px]">
{triggerLabel}
</span>
}
>
{levels.map((l) => (
<PickerItem
key={l.value}
selected={l.value === value}
onClick={() => void select(l.value)}
>
{/* PickerItem wraps children in a flex `<span>`. Putting a
`<div>` inside that <span> is block-in-inline (invalid HTML5)
and triggers browser quirks that shift descendant x-position.
Use a `<span>` with explicit `block` + `text-left` so layout
is deterministic across rows regardless of whether the label
row has the `default` badge sibling. */}
{/* No model-factory-default badge here on purpose: when the
picker is "Follow CLI config" (value === ""), Multica omits
`--effort` and the local CLI config decides — the model's
factory default is irrelevant to what actually fires, so
flagging one option as "default" was misleading. */}
<span className="block min-w-0 flex-1 text-left">
<span className="truncate text-[13px] font-medium">{l.label}</span>
{l.description && (
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">
{l.description}
</span>
)}
</span>
</PickerItem>
))}
{value && (
<button
type="button"
onClick={() => void select("")}
className="mt-1 flex w-full items-center border-t px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent/50"
title={t(($) => $.pickers.thinking_clear_title)}
>
{t(($) => $.pickers.thinking_clear)}
</button>
)}
</PropertyPicker>
);
}

View File

@@ -1,193 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type {
RuntimeModel,
RuntimeModelListRequest,
} from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../../locales/en/common.json";
import enAgents from "../../../locales/en/agents.json";
import enIssues from "../../../locales/en/issues.json";
const TEST_RESOURCES = {
en: { common: enCommon, agents: enAgents, issues: enIssues },
};
const mockInitiateListModels = vi.hoisted(() => vi.fn());
const mockGetListModelsResult = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/api", () => ({
api: {
initiateListModels: (...args: unknown[]) =>
mockInitiateListModels(...args),
getListModelsResult: (...args: unknown[]) =>
mockGetListModelsResult(...args),
},
}));
import { ThinkingPropRow } from "./thinking-prop-row";
const CLAUDE_MODEL: RuntimeModel = {
id: "claude-sonnet-4-6",
label: "Claude Sonnet 4.6",
default: true,
thinking: {
supported_levels: [
{ value: "none", label: "None" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
default_level: "medium",
},
};
// Model without thinking metadata — what the row sees when the agent's
// model swap landed on a non-thinking runtime, or when the daemon catalog
// shrank and stopped emitting `thinking` for this id.
const NO_THINKING_MODEL: RuntimeModel = {
id: "gemini-2.5-pro",
label: "Gemini 2.5 Pro",
default: true,
};
function listResult(models: RuntimeModel[]): RuntimeModelListRequest {
return {
id: "req-1",
runtime_id: "runtime-1",
status: "completed",
models,
supported: true,
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
};
}
function renderRow(
props: Partial<React.ComponentProps<typeof ThinkingPropRow>> = {},
) {
const onChange = vi.fn();
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const utils = render(
// PropRow uses CSS subgrid, so wrap with the same column tracks the
// inspector parent declares — otherwise the row mounts without a
// grid context and the column layout warns. Behaviour we care about
// (visibility + clear flow) is independent of layout.
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
<ThinkingPropRow
runtimeId="runtime-1"
runtimeOnline
model="claude-sonnet-4-6"
value=""
canEdit
onChange={onChange}
{...props}
/>
</div>
</QueryClientProvider>
</I18nProvider>,
);
return { ...utils, onChange, queryClient };
}
describe("ThinkingPropRow", () => {
beforeEach(() => {
vi.clearAllMocks();
mockInitiateListModels.mockResolvedValue(listResult([CLAUDE_MODEL]));
mockGetListModelsResult.mockResolvedValue(listResult([CLAUDE_MODEL]));
});
afterEach(() => {
cleanup();
});
it("hides the row when the active model has no thinking levels and nothing is persisted", async () => {
mockInitiateListModels.mockResolvedValue(listResult([NO_THINKING_MODEL]));
renderRow({ model: "gemini-2.5-pro", value: "" });
// ThinkingPropRow returns null when levels are empty and value is
// empty — both initially (data undefined) and after discovery
// (NO_THINKING_MODEL has no `thinking` block). The `useQuery` hook
// runs before the early null return on first render, so the
// subscription is established and discovery still fires. In
// production this is also covered by the sibling ModelPicker
// mounted next to the row in agent-detail-inspector.
await waitFor(() => {
expect(mockInitiateListModels).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.queryByText("Thinking")).toBeNull();
});
});
it("hides the row while the runtime is offline (no query fires)", () => {
renderRow({ runtimeOnline: false, value: "" });
// Query disabled when runtimeOnline=false, so no models, levels stay
// empty, value is empty → row stays hidden.
expect(screen.queryByText("Thinking")).toBeNull();
expect(mockInitiateListModels).not.toHaveBeenCalled();
});
it("renders the row with the persisted raw token when levels are empty but value is set (stale orphan)", async () => {
// The agent persisted `thinking_level=xhigh` while it was on a
// thinking-capable model, then was swapped to gemini (or the CLI
// catalog shrank). PR1's behavior is daemon-side warn/drop, not a
// synchronous DB clear, so the frontend must surface the orphan
// token and let the user clear it explicitly.
mockInitiateListModels.mockResolvedValue(listResult([NO_THINKING_MODEL]));
renderRow({ model: "gemini-2.5-pro", value: "xhigh" });
await screen.findByText("Thinking");
// The picker chip carries the raw value when it's not in the catalog.
expect(await screen.findByText("xhigh")).toBeInTheDocument();
});
it("clears the orphan value via the picker footer, emitting onChange(\"\")", async () => {
mockInitiateListModels.mockResolvedValue(listResult([NO_THINKING_MODEL]));
const { onChange } = renderRow({
model: "gemini-2.5-pro",
value: "xhigh",
});
// Wait until the row mounts with the orphan value, then open the
// popover and fire the clear footer. The footer is the only target
// matching the i18n `thinking_clear_title` copy.
await screen.findByText("xhigh");
fireEvent.click(screen.getByRole("button"));
const clearButton = await screen.findByTitle(/Clear the override/i);
fireEvent.click(clearButton);
expect(onChange).toHaveBeenCalledWith("");
});
it("renders the row with the matched label when the model still advertises the value", async () => {
renderRow({ value: "high" });
await screen.findByText("Thinking");
// Both the chip and the tooltip carry "High".
expect((await screen.findAllByText("High")).length).toBeGreaterThan(0);
});
it("renders the row with \"Follow CLI config\" when value is empty and the model exposes levels", async () => {
renderRow({ value: "" });
await screen.findByText("Thinking");
// Empty value means Multica omits --effort, so the local CLI's
// config decides — chip + tooltip both read "Follow CLI config".
expect((await screen.findAllByText("Follow CLI config")).length).toBeGreaterThan(0);
});
});

View File

@@ -1,71 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { RuntimeModel } from "@multica/core/types";
import { runtimeModelsOptions } from "@multica/core/runtimes";
import { PropRow } from "../../../common/prop-row";
import { useT } from "../../../i18n";
import { ThinkingPicker } from "./thinking-picker";
/**
* Thinking row for the agent inspector. Hidden when the active model has
* no `supported_levels` advertised AND nothing is persisted, so providers
* that don't expose reasoning never surface an empty row. If the agent
* already has a `thinking_level` saved (model swap into a non-thinking
* runtime, or the daemon / CLI catalog shrank and dropped the entry),
* we still render the row so the user can see the orphan token the
* backend is still sending and explicit-clear it via the picker footer.
* PR1's per-model invalid behavior is daemon-side warn/drop, not a
* synchronous DB clear, so the frontend has to surface the persisted
* state honestly.
*
* Reuses the shared runtime-models query so it hits the same 60s cache
* as the model picker; no extra round-trip on the inspector's hot path.
* The sibling ModelPicker mounts unconditionally next to this row, so
* the shared query subscription is established by the inspector mount
* itself — returning null here does NOT cancel discovery.
*/
export function ThinkingPropRow({
runtimeId,
runtimeOnline,
model,
value,
canEdit,
onChange,
}: {
runtimeId: string | null;
runtimeOnline: boolean;
model: string;
value: string;
canEdit: boolean;
onChange: (next: string) => Promise<void> | void;
}) {
const { t } = useT("agents");
const modelsQuery = useQuery(
runtimeModelsOptions(runtimeOnline ? runtimeId : null),
);
const models = modelsQuery.data?.models ?? [];
const entry = pickModelEntry(models, model);
const levels = entry?.thinking?.supported_levels ?? [];
if (levels.length === 0 && !value) return null;
return (
<PropRow label={t(($) => $.inspector.prop_thinking)} interactive={false}>
<ThinkingPicker
value={value}
levels={levels}
canEdit={canEdit}
onChange={onChange}
/>
</PropRow>
);
}
function pickModelEntry(
models: RuntimeModel[],
model: string,
): RuntimeModel | undefined {
if (model) return models.find((m) => m.id === model);
return models.find((m) => m.default) ?? models[0];
}

View File

@@ -183,7 +183,14 @@ export function ModelDropdown({
}`}
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{m.label}</div>
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{m.label}</span>
{m.default && (
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary">
{t(($) => $.pickers.model_default_badge)}
</span>
)}
</div>
{m.label !== m.id && (
<div className="truncate text-xs text-muted-foreground">
{m.id}

View File

@@ -10,7 +10,6 @@ import type { TaskFailureReason } from "@multica/core/types";
export const failureReasonLabel: Record<TaskFailureReason, string> = {
agent_error: "Agent execution error",
timeout: "Task timed out",
codex_semantic_inactivity: "Codex semantic inactivity timeout",
runtime_offline: "Daemon offline",
runtime_recovery: "Daemon restarted",
manual: "Cancelled by user",

View File

@@ -8,7 +8,6 @@ import {
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import {
useUpdateAutopilot,
useDeleteAutopilot,
@@ -58,7 +57,6 @@ import { TranscriptButton } from "../../common/task-transcript";
import { AutopilotDialog } from "./autopilot-dialog";
import { WebhookPayloadPreview } from "./webhook-payload-preview";
import { WebhookDeliveriesSection } from "./webhook-deliveries-section";
import { ProjectIcon } from "../../projects/components/project-icon";
import { useT } from "../../i18n";
function formatDate(date: string): string {
@@ -585,11 +583,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
const updateAutopilot = useUpdateAutopilot();
const deleteAutopilot = useDeleteAutopilot();
const triggerAutopilot = useTriggerAutopilot();
const projectId = data?.autopilot.project_id ?? null;
const { data: project, isLoading: projectLoading } = useQuery({
...projectDetailOptions(wsId, projectId ?? ""),
enabled: Boolean(projectId),
});
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
@@ -749,28 +742,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
{t(($) => $.execution_mode[autopilot.execution_mode as AutopilotExecutionMode])}
</div>
</div>
{autopilot.execution_mode === "create_issue" && (
<div>
<label className="text-xs text-muted-foreground">{t(($) => $.detail.field_project)}</label>
<div className="mt-1 min-w-0">
{!autopilot.project_id ? (
<span className="text-muted-foreground">{t(($) => $.detail.no_project)}</span>
) : projectLoading ? (
<Skeleton className="h-5 w-32" />
) : project ? (
<AppLink
href={wsPaths.projectDetail(project.id)}
className="inline-flex max-w-full items-center gap-1.5 text-foreground hover:underline"
>
<ProjectIcon project={project} size="md" />
<span className="truncate">{project.title}</span>
</AppLink>
) : (
<span className="text-muted-foreground">{t(($) => $.detail.project_unavailable)}</span>
)}
</div>
</div>
)}
{autopilot.description && (
<div className="col-span-2">
<label className="text-xs text-muted-foreground">{t(($) => $.detail.field_prompt)}</label>
@@ -865,7 +836,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
initial={{
title: autopilot.title,
description: autopilot.description ?? "",
project_id: autopilot.project_id ?? null,
assignee_type: autopilot.assignee_type,
assignee_id: autopilot.assignee_id,
execution_mode: autopilot.execution_mode as AutopilotExecutionMode,

View File

@@ -10,7 +10,6 @@ import {
Clock,
Copy,
FilePlus2,
FolderKanban,
Maximize2,
Minimize2,
Play,
@@ -39,7 +38,6 @@ import { TimezonePicker } from "./pickers/timezone-picker";
import { useCurrentWorkspace } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
import { projectListOptions } from "@multica/core/projects/queries";
import {
useCreateAutopilot,
useCreateAutopilotTrigger,
@@ -55,8 +53,6 @@ import type {
} from "@multica/core/types";
import { TitleEditor, ContentEditor } from "../../editor";
import { ActorAvatar } from "../../common/actor-avatar";
import { ProjectPicker } from "../../projects/components/project-picker";
import { ProjectIcon } from "../../projects/components/project-icon";
import { AgentPicker, type AssigneeSelection } from "./pickers/agent-picker";
import {
getDefaultTriggerConfig,
@@ -76,7 +72,6 @@ import { formatSchedulePartialFailureToast } from "./autopilot-dialog-toast";
export interface AutopilotInitial {
title: string;
description: string;
project_id: string | null;
assignee_type: AutopilotAssigneeType;
assignee_id: string;
execution_mode: AutopilotExecutionMode;
@@ -250,7 +245,6 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: squads = [] } = useQuery(squadListOptions(wsId));
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const [isExpanded, setIsExpanded] = useState(false);
const isCreate = props.mode === "create";
@@ -260,7 +254,6 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const [title, setTitle] = useState(initial.title ?? "");
const [description, setDescription] = useState(initial.description ?? "");
const [projectId, setProjectId] = useState<string | null>(initial.project_id ?? null);
const [assigneeType, setAssigneeType] = useState<AutopilotAssigneeType>(
initial.assignee_type ?? "agent",
);
@@ -318,10 +311,6 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const agent = agents.find((a) => a.id === assigneeId);
return agent ? { name: agent.name, description: agent.description } : null;
}, [agents, squads, assigneeId, assigneeType]);
const selectedProject = useMemo(
() => projects.find((project) => project.id === projectId) ?? null,
[projects, projectId],
);
const handleAssigneeChange = (next: AssigneeSelection) => {
setAssigneeType(next.type);
@@ -351,7 +340,6 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const autopilot = await createAutopilot.mutateAsync({
title: title.trim(),
description: description.trim() || undefined,
project_id: executionMode === "create_issue" ? projectId : null,
assignee_type: assigneeType,
assignee_id: assigneeId,
execution_mode: executionMode,
@@ -399,7 +387,6 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
id: props.autopilotId,
title: title.trim(),
description: description.trim() || null,
project_id: executionMode === "create_issue" ? projectId : null,
assignee_type: assigneeType,
assignee_id: assigneeId,
execution_mode: executionMode,
@@ -588,14 +575,6 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
<OutputModeSection mode={executionMode} onChange={setExecutionMode} />
{executionMode === "create_issue" && (
<ProjectSection
projectId={projectId}
selectedProject={selectedProject}
onChange={setProjectId}
/>
)}
{isCreate && (
<TriggerKindSection kind={triggerKind} onChange={setTriggerKind} />
)}
@@ -774,49 +753,6 @@ function OutputModeSection({
);
}
function ProjectSection({
projectId,
selectedProject,
onChange,
}: {
projectId: string | null;
selectedProject: { title: string; icon: string | null } | null;
onChange: (projectId: string | null) => void;
}) {
const { t } = useT("autopilots");
return (
<div>
<SectionLabel>{t(($) => $.dialog.section_project)}</SectionLabel>
<ProjectPicker
projectId={projectId}
onUpdate={(updates) => onChange(updates.project_id ?? null)}
align="start"
triggerRender={
<button
type="button"
className={cn(
"w-full flex items-center gap-2.5 rounded-md border bg-background px-3 py-2 text-left",
"hover:bg-accent/40 transition-colors cursor-pointer",
)}
>
{selectedProject ? (
<ProjectIcon project={selectedProject} size="md" />
) : (
<span className="inline-flex size-5 items-center justify-center rounded-md bg-muted text-muted-foreground">
<FolderKanban className="size-3.5" />
</span>
)}
<span className="flex-1 min-w-0 truncate text-sm font-medium">
{selectedProject?.title ?? t(($) => $.dialog.no_project)}
</span>
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
</button>
}
/>
</div>
);
}
function ScheduleSection({
config,
onChange,

View File

@@ -19,7 +19,7 @@ import {
import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle, Copy } from "lucide-react";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
import { isTaskMessageTaskId, taskMessagesOptions } from "@multica/core/chat/queries";
import { taskMessagesOptions } from "@multica/core/chat/queries";
import { Markdown } from "@multica/views/common/markdown";
import { copyMarkdown } from "../../editor";
import { AttachmentList } from "../../issues/components/comment-card";
@@ -67,10 +67,9 @@ export function ChatMessageList({
// Live timeline for the in-flight task. useRealtimeSync keeps this cache
// current via setQueryData on task:message events.
const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted;
const canFetchLiveTimeline = isTaskMessageTaskId(pendingTaskId) && !pendingAlreadyPersisted;
const { data: liveTaskMessages } = useQuery({
...taskMessagesOptions(pendingTaskId ?? ""),
enabled: canFetchLiveTimeline,
enabled: showLiveTimeline,
});
const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem);
const hasLive = showLiveTimeline && liveTimeline.length > 0;
@@ -180,14 +179,13 @@ function AssistantMessage({
isPending: boolean;
}) {
const taskId = message.task_id;
const canFetchTaskMessages = isTaskMessageTaskId(taskId);
// Use the shared taskMessagesOptions so this cache entry is the same one
// seeded by useRealtimeSync during task execution — zero refetch when the
// task finishes, since WS already populated it.
const { data: taskMessages } = useQuery({
...taskMessagesOptions(taskId ?? ""),
enabled: canFetchTaskMessages,
enabled: !!taskId,
});
const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(toTimelineItem);
@@ -623,3 +621,4 @@ function ErrorRow({ item }: { item: ChatTimelineItem }) {
}
// ─── Shared ──────────────────────────────────────────────────────────────

View File

@@ -8,10 +8,10 @@ import {
SelectValue,
} from "@multica/ui/components/ui/select";
// Curated fallback list used when the runtime lacks `Intl.supportedValuesOf`.
// Exported so every timezone picker draws from one source instead of
// drifting copies.
export const COMMON_TIMEZONES = [
// Common IANA zones surfaced as quick picks. Used as the fallback option set
// when Intl.supportedValuesOf is not available, and promoted to the top of
// the list when it is.
const COMMON_TIMEZONES = [
"UTC",
"America/Los_Angeles",
"America/Denver",
@@ -33,25 +33,13 @@ export const COMMON_TIMEZONES = [
"Pacific/Auckland",
];
let cachedBrowserTZ: string | null = null;
export function browserTimezone(): string {
if (cachedBrowserTZ !== null) return cachedBrowserTZ;
try {
cachedBrowserTZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz || "UTC";
} catch {
cachedBrowserTZ = "UTC";
return "UTC";
}
return cachedBrowserTZ;
}
// Clears the module-level browserTimezone() cache. Browser code never
// needs this — the tz is stable for a session — but the cache survives
// across Vitest files in the same worker, so any test that stubs
// `Intl.DateTimeFormat` (directly or via a fake timezone) MUST call this
// in `beforeEach`, otherwise a value cached by an earlier suite leaks in.
// Tests that mock the whole `./timezone-select` module are unaffected.
export function resetBrowserTimezoneCache(): void {
cachedBrowserTZ = null;
}
type IntlWithSupportedValues = typeof Intl & {
@@ -76,6 +64,10 @@ export function timezoneOptions(current: string): string[] {
).filter(Boolean);
}
// Shared single-select timezone picker. Surfaces the browser-resolved zone
// with a translated suffix (passed in by the caller — the picker itself stays
// i18n-namespace agnostic), followed by a curated set of common IANA zones
// and everything Intl.supportedValuesOf exposes.
export function TimezoneSelect({
value,
onValueChange,

View File

@@ -1,67 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
const userRef = vi.hoisted(
() => ({ current: null as { timezone?: string | null } | null }),
);
vi.mock("@multica/core/auth", () => {
type AuthState = { user: typeof userRef.current };
const useAuthStore = Object.assign(
(sel: (s: AuthState) => unknown) => sel({ user: userRef.current }),
{ getState: () => ({ user: userRef.current }) },
);
return { useAuthStore };
});
vi.mock("./timezone-select", () => ({
browserTimezone: () => "America/Chicago",
}));
import { useViewingTimezone } from "./use-viewing-timezone";
describe("useViewingTimezone", () => {
beforeEach(() => {
userRef.current = null;
});
it("returns the stored preference when the user pinned one", () => {
userRef.current = { timezone: "Asia/Tokyo" };
const { result } = renderHook(() => useViewingTimezone());
expect(result.current).toBe("Asia/Tokyo");
});
it("falls back to the browser tz when there is no user", () => {
userRef.current = null;
const { result } = renderHook(() => useViewingTimezone());
expect(result.current).toBe("America/Chicago");
});
it("falls back to the browser tz when timezone is null", () => {
userRef.current = { timezone: null };
const { result } = renderHook(() => useViewingTimezone());
expect(result.current).toBe("America/Chicago");
});
it("falls back to the browser tz when timezone is blank", () => {
userRef.current = { timezone: " " };
const { result } = renderHook(() => useViewingTimezone());
expect(result.current).toBe("America/Chicago");
});
// The preferences clear-flow PATCHes timezone: "" and the server may echo
// the empty string back before normalising it to null. The hook must
// treat "" as "no preference" and fall back to the browser tz.
it("falls back to the browser tz when timezone is an empty string", () => {
userRef.current = { timezone: "" };
const { result } = renderHook(() => useViewingTimezone());
expect(result.current).toBe("America/Chicago");
});
// Auth store still initialising: user is undefined, not null.
it("falls back to the browser tz when the user is undefined", () => {
userRef.current = undefined as never;
const { result } = renderHook(() => useViewingTimezone());
expect(result.current).toBe("America/Chicago");
});
});

View File

@@ -1,9 +0,0 @@
import { useAuthStore } from "@multica/core/auth";
import { browserTimezone } from "./timezone-select";
// Viewer's IANA tz: stored user preference, else browser-detected, else UTC.
export function useViewingTimezone(): string {
const stored = useAuthStore((s) => s.user?.timezone ?? null);
if (stored && stored.trim() !== "") return stored;
return browserTimezone();
}

View File

@@ -1,100 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import { renderWithI18n } from "../../test/i18n";
// The viewing timezone flows: auth store `user.timezone` → useViewingTimezone()
// → every dashboard query key. This test pins that chain: when the stored
// timezone changes, the dashboard report query keys must change, which is
// what makes TanStack Query refetch under the new tz.
// Capture every queryKey passed to useQuery. queryOptions() inside the
// dashboard options builders runs for real, so the key is the production key.
const queryKeys = vi.hoisted(() => [] as unknown[][]);
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQuery: (opts: { queryKey: unknown[] }) => {
queryKeys.push(opts.queryKey);
return { data: undefined, isLoading: true };
},
};
});
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
const tzRef = vi.hoisted(() => ({ current: "UTC" as string | null }));
vi.mock("@multica/core/auth", () => {
type AuthState = { user: { timezone: string | null } | null };
const state = (): AuthState => ({ user: { timezone: tzRef.current } });
const useAuthStore = Object.assign(
(sel?: (s: AuthState) => unknown) => (sel ? sel(state()) : state()),
{ getState: state },
);
return { useAuthStore };
});
vi.mock("@multica/core/runtimes/custom-pricing-store", () => {
const state = () => ({ pricings: {} });
const useCustomPricingStore = Object.assign(
(sel?: (s: ReturnType<typeof state>) => unknown) =>
sel ? sel(state()) : state(),
{ getState: state },
);
return { useCustomPricingStore };
});
import { DashboardPage } from "./dashboard-page";
describe("DashboardPage — viewing timezone drives the query key", () => {
beforeEach(() => {
queryKeys.length = 0;
cleanup();
});
// The `tz` segment is the last element of every dashboard key
// (see dashboardKeys in @multica/core/dashboard/queries).
function tzSegments(): unknown[] {
return queryKeys
.filter((k) => k[0] === "dashboard")
.map((k) => k[k.length - 1]);
}
it("uses the stored timezone in every dashboard query key", () => {
tzRef.current = "UTC";
renderWithI18n(<DashboardPage />);
const tzs = tzSegments();
expect(tzs.length).toBeGreaterThan(0);
expect(tzs.every((tz) => tz === "UTC")).toBe(true);
});
it("flips the query key when the stored timezone changes", () => {
tzRef.current = "UTC";
renderWithI18n(<DashboardPage />);
const utcKeys = queryKeys.filter((k) => k[0] === "dashboard");
queryKeys.length = 0;
cleanup();
tzRef.current = "Asia/Tokyo";
renderWithI18n(<DashboardPage />);
const tokyoKeys = queryKeys.filter((k) => k[0] === "dashboard");
expect(utcKeys.length).toBe(tokyoKeys.length);
expect(utcKeys.length).toBeGreaterThan(0);
// Same number of dashboard queries, but no key is shared between the
// two timezones — so TanStack Query treats every series as a fresh
// fetch and refetches under the new tz.
for (let i = 0; i < utcKeys.length; i++) {
expect(utcKeys[i]).not.toEqual(tokyoKeys[i]);
}
});
});

View File

@@ -21,7 +21,6 @@ import {
dashboardRunTimeDailyOptions,
} from "@multica/core/dashboard";
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
import { useViewingTimezone } from "../../common/use-viewing-timezone";
import { PageHeader } from "../../layout/page-header";
import { KpiCard } from "../../runtimes/components/shared";
import {
@@ -65,9 +64,9 @@ import {
// current value isn't in the new dimension's allowed set (see
// `handleDimChange` below).
//
// 1d semantic: "today" (the natural calendar day from 00:00 in the viewer's
// timezone), not "the last 24 hours". The client-side `dailyCutoffIso` filter
// below enforces this even at the midnight edge.
// 1d semantic: "today" (the natural calendar day from 00:00 in UTC, matching
// the rollup's bucket_date axis), not "the last 24 hours". The client-side
// `dailyCutoffIso` filter below enforces this even at the midnight edge.
const TIME_RANGES = [
{ label: "1d", days: 1, dims: ["daily"] as const },
{ label: "7d", days: 7, dims: ["daily"] as const },
@@ -104,6 +103,14 @@ function fmtMoney(n: number): string {
return `$${n.toFixed(2)}`;
}
// Weekly aggregation is locked to UTC: the dashboard daily rollup buckets
// data by UTC `bucket_date` (and the raw fallback queries by `DATE(...)`,
// also UTC), so any other zone for client-side week boundaries would put
// cross-midnight rows into the wrong calendar week. Runtime-detail can use
// the runtime's IANA tz because its rollup is materialized in that tz; the
// workspace rollup has no equivalent, so weekly is UTC-only here.
const WEEK_TZ = "UTC";
// Local segmented control — same visual language the runtime usage section
// uses for its period / tab toggles. shadcn's Tabs is wired for full tab
// pages with ARIA semantics the compact toolbar pill doesn't need.
@@ -150,7 +157,6 @@ function Segmented<T extends string | number>({
export function DashboardPage() {
const { t } = useT("usage");
const wsId = useWorkspaceId();
const viewTZ = useViewingTimezone();
const [dim, setDim] = useState<Dim>("daily");
const [days, setDays] = useState<TimeRange>(30);
const [projectValue, setProjectValue] = useState<string>(ALL_PROJECTS);
@@ -192,16 +198,12 @@ export function DashboardPage() {
const chartFetchDays = dim === "weekly" ? weekCount * 7 : days;
const dailyQuery = useQuery(
dashboardUsageDailyOptions(wsId, chartFetchDays, projectId, viewTZ),
);
const byAgentQuery = useQuery(
dashboardUsageByAgentOptions(wsId, days, projectId, viewTZ),
);
const runTimeQuery = useQuery(
dashboardAgentRunTimeOptions(wsId, days, projectId, viewTZ),
dashboardUsageDailyOptions(wsId, chartFetchDays, projectId),
);
const byAgentQuery = useQuery(dashboardUsageByAgentOptions(wsId, days, projectId));
const runTimeQuery = useQuery(dashboardAgentRunTimeOptions(wsId, days, projectId));
const runTimeDailyQuery = useQuery(
dashboardRunTimeDailyOptions(wsId, chartFetchDays, projectId, viewTZ),
dashboardRunTimeDailyOptions(wsId, chartFetchDays, projectId),
);
const dailyUsage = dailyQuery.data ?? EMPTY_DAILY;
@@ -211,14 +213,14 @@ export function DashboardPage() {
// Daily-aggregation surfaces (cost/tokens/time/tasks KPIs and the Daily
// trend chart) re-scope to the user-selected `days` even when we
// over-fetched for the weekly chart. The cutoff is anchored on the viewer's
// timezone — the same axis the backend slices `bucket_hour` on — so it
// lands on the same calendar boundary. Applied in both dims so 1d strictly
// means "today" even at the midnight edge where a wall-clock cutoff would
// over-fetched for the weekly chart. UTC matches the bucket_date the
// backend filters on, so the cutoff lands on the same calendar boundary
// the rollup used. Applied in both dims so 1d strictly means "today" even
// at the midnight UTC edge where the server's wall-clock cutoff would
// otherwise include yesterday.
const dailyCutoffIso = useMemo(
() => addDaysIso(todayIso(viewTZ), -(days - 1)),
[days, viewTZ],
() => addDaysIso(todayIso(WEEK_TZ), -(days - 1)),
[days],
);
const dailyUsageInWindow = useMemo(
() => dailyUsage.filter((u) => u.date >= dailyCutoffIso),
@@ -271,21 +273,21 @@ export function DashboardPage() {
// leftmost trailing week always has data even when the user-selected `days`
// (e.g. 30D) is shorter than the chart's `weekCount * 7` span. Buckets are
// pre-zeroed inside the helpers, so sparse weeks render as empty bars
// instead of being dropped (MUL-2382 weekly window scoping). Week
// boundaries follow the viewer's timezone.
// instead of being dropped (MUL-2382 weekly window scoping). Locked to
// UTC so the week boundaries match the backend's UTC `bucket_date`.
const weekly = useMemo(
() => aggregateByWeek(dailyUsage, viewTZ, weekCount),
[dailyUsage, viewTZ, weekCount],
() => aggregateByWeek(dailyUsage, WEEK_TZ, weekCount),
[dailyUsage, weekCount],
);
const weeklyCost = weekly.weeklyCostStack;
const weeklyTokens = weekly.weeklyTokens;
const weeklyTime = useMemo(
() => aggregateWeeklyTime(runTimeDailyRows, viewTZ, weekCount),
[runTimeDailyRows, viewTZ, weekCount],
() => aggregateWeeklyTime(runTimeDailyRows, WEEK_TZ, weekCount),
[runTimeDailyRows, weekCount],
);
const weeklyTasks = useMemo(
() => aggregateWeeklyTasks(runTimeDailyRows, viewTZ, weekCount),
[runTimeDailyRows, viewTZ, weekCount],
() => aggregateWeeklyTasks(runTimeDailyRows, WEEK_TZ, weekCount),
[runTimeDailyRows, weekCount],
);
const agentTokenRows = useMemo(
() => aggregateAgentTokens(byAgentUsage),

View File

@@ -347,18 +347,17 @@ function PastRow({ task, issueId }: { task: AgentTask; issueId: string }) {
? failureReasonLabel[task.failure_reason as TaskFailureReason]
: null;
// Retry only makes sense for terminal-but-not-success rows. Passing
// task.id targets this specific row's agent — without it, the rerun
// endpoint would fall back to the issue's current assignee and the
// wrong agent would fire on rows whose agent has since been displaced
// (e.g. reassignment, squad worker, or a one-off @-mention agent).
// Retry only makes sense for terminal-but-not-success rows. The rerun
// endpoint creates a fresh task on the issue's current agent assignee
// (not necessarily this row's agent) — clicking retry on a row whose
// agent has since been reassigned will rerun under the new assignee.
const canRetry = task.status === "failed" || task.status === "cancelled";
const handleRetry = async () => {
if (retrying) return;
setRetrying(true);
try {
await api.rerunIssue(issueId, task.id);
await api.rerunIssue(issueId);
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.execution_log.retry_failed));
} finally {

View File

@@ -130,7 +130,6 @@
"section_skills": "Skills",
"prop_runtime": "Runtime",
"prop_model": "Model",
"prop_thinking": "Thinking",
"prop_visibility": "Visibility",
"prop_concurrency": "Concurrency",
"prop_owner": "Owner",
@@ -167,16 +166,13 @@
"model_managed_by_runtime": "Managed by runtime",
"model_search_placeholder": "Search or type a model ID",
"model_discovering": "Discovering models…",
"model_default_badge": "default",
"model_empty": "No models available",
"model_empty_with_dot": "No models available.",
"model_custom_tooltip": "Use \"{{value}}\" as a custom model id",
"model_custom_use": "Use \"{{value}}\"",
"model_clear": "Clear (use provider default)",
"model_clear_title": "Clear and fall back to the runtime's provider default",
"thinking_default": "Follow CLI config",
"thinking_tooltip": "Thinking · {{value}}",
"thinking_clear": "Follow CLI config",
"thinking_clear_title": "Clear the override and let the local CLI config (claude/codex) decide the reasoning level"
"model_clear_title": "Clear and fall back to the runtime's provider default"
},
"model_dropdown": {
"label": "Model",

View File

@@ -72,9 +72,6 @@
"section_danger": "Danger Zone",
"field_agent": "Agent",
"field_output_mode": "Output Mode",
"field_project": "Project",
"no_project": "No project",
"project_unavailable": "Project unavailable",
"field_prompt": "Prompt",
"add_trigger": "Add trigger",
"no_triggers": "No triggers configured. Add a schedule to run automatically.",
@@ -244,8 +241,6 @@
"section_assignee": "Assignee",
"select_agent": "Select agent",
"select_assignee": "Select agent or squad",
"section_project": "Project",
"no_project": "No project",
"section_output_mode": "Output mode",
"section_schedule": "Schedule",
"section_trigger_kind": "Trigger",

View File

@@ -117,7 +117,6 @@
"title_placeholder": "Issue title",
"description_placeholder": "Add description...",
"more_options_aria": "More options",
"title_required": "Enter a title to create",
"submit": "Create Issue",
"submitting": "Creating...",
"toast_created": "Issue created",

View File

@@ -3,11 +3,7 @@
"title": "Projects",
"new_project": "New project",
"empty": "No projects yet",
"create_first": "Create your first project",
"view_compact": "Compact",
"view_comfortable": "Comfortable",
"search_placeholder": "Search projects...",
"no_search_results": "No results match your search"
"create_first": "Create your first project"
},
"table": {
"name": "Name",
@@ -61,7 +57,6 @@
"empty_issues_title": "No issues linked",
"empty_issues_hint": "Create a new issue or assign existing ones to this project.",
"empty_issues_new_button": "New Issue",
"no_issues_yet": "No issues yet",
"toast_link_copied": "Link copied",
"toast_project_deleted": "Project deleted",
"toast_move_issue_failed": "Failed to move issue"

View File

@@ -101,6 +101,7 @@
"diagnostics_title": "Diagnostics",
"diagnostics_cli": "CLI",
"diagnostics_visibility": "Visibility",
"diagnostics_timezone": "Timezone",
"visibility_label": {
"private": "Private",
"public": "Public"
@@ -111,6 +112,10 @@
},
"visibility_toast_updated": "Visibility set to {{visibility}}",
"visibility_toast_failed": "Failed to update visibility",
"timezone_browser_suffix": " (browser)",
"timezone_hint": "Token-usage charts on this runtime bucket dates by this timezone.",
"timezone_toast_updated": "Timezone updated to {{tz}}",
"timezone_toast_failed": "Failed to update timezone",
"delete_dialog": {
"title": "Delete Runtime",
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
@@ -155,42 +160,6 @@
"view_runtime": "View runtime",
"create_agent": "Create an agent"
},
"cloud_runtime": {
"action": "Cloud Runtime",
"title": "Cloud Runtime",
"description": "Launch a managed cloud node and watch its Fleet status.",
"create_title": "New node",
"create_hint": "Choose a name, instance type, and disk size. Fleet applies the rest.",
"nodes_title": "Fleet nodes",
"refresh": "Refresh",
"nodes_empty": "No cloud nodes",
"nodes_failed": "Cloud Runtime unavailable",
"nodes_failed_hint": "Fleet did not return a node list.",
"node_fallback_name": "Cloud node",
"create": "Create node",
"creating": "Creating...",
"cancel": "Cancel",
"toast_created": "Cloud Runtime node created",
"toast_create_failed": "Failed to create Cloud Runtime node",
"fields": {
"name": "Name",
"instance_type": "Instance type",
"region": "Region",
"disk_size": "Disk size GiB",
"image_id": "AMI ID",
"subnet_id": "Subnet ID",
"key_name": "Key pair",
"bootstrap_pat": "Bootstrap PAT"
},
"placeholders": {
"name": "cloud-dev-01",
"key_name": "dev-key"
},
"validation": {
"instance_type_required": "Instance type is required",
"disk_size_invalid": "Disk size must be a positive integer"
}
},
"update": {
"cli_version_label": "CLI Version:",
"version_unknown": "unknown",

View File

@@ -11,12 +11,6 @@
"english": "English",
"chinese": "中文",
"sync_failed": "Language saved on this device, but failed to sync to your account. Other devices may show the previous language."
},
"timezone": {
"title": "Viewing Timezone",
"browser_suffix": " (browser)",
"hint": "Used for dashboards, charts, and any 'today' label shown to you. It's a personal preference that applies across all your workspaces.",
"sync_failed": "Failed to save your timezone preference."
}
},
"page": {

View File

@@ -9,13 +9,6 @@
"details_section": "Details",
"archive_button": "Archive"
},
"archive_dialog": {
"title": "Archive this squad?",
"description": "\"{{name}}\" will be archived. Issues currently assigned to this squad will be transferred to its leader. This can't be undone — create a new squad if you need the routing back.",
"cancel": "Cancel",
"confirm": "Archive",
"archiving": "Archiving…"
},
"name_editor": {
"cancel": "Cancel"
},

View File

@@ -126,7 +126,6 @@
"section_skills": "skill",
"prop_runtime": "运行时",
"prop_model": "模型",
"prop_thinking": "思考",
"prop_visibility": "可见性",
"prop_concurrency": "并发",
"prop_owner": "所有者",
@@ -163,16 +162,13 @@
"model_managed_by_runtime": "由运行时管理",
"model_search_placeholder": "搜索或输入模型 ID",
"model_discovering": "正在发现模型...",
"model_default_badge": "默认",
"model_empty": "暂无可用模型",
"model_empty_with_dot": "暂无可用模型。",
"model_custom_tooltip": "使用\"{{value}}\"作为自定义模型 ID",
"model_custom_use": "使用\"{{value}}\"",
"model_clear": "清除(使用提供方默认)",
"model_clear_title": "清除并回退到运行时的提供方默认",
"thinking_default": "跟随 CLI 配置",
"thinking_tooltip": "思考 · {{value}}",
"thinking_clear": "跟随 CLI 配置",
"thinking_clear_title": "清除覆盖,让本地 CLIclaude/codex的配置决定推理级别"
"model_clear_title": "清除并回退到运行时的提供方默认"
},
"model_dropdown": {
"label": "模型",

View File

@@ -72,9 +72,6 @@
"section_danger": "危险操作",
"field_agent": "智能体",
"field_output_mode": "输出模式",
"field_project": "关联项目",
"no_project": "不关联项目",
"project_unavailable": "项目不可用",
"field_prompt": "Prompt",
"add_trigger": "添加触发器",
"no_triggers": "未配置触发器。添加一个时间表让它自动运行。",
@@ -244,8 +241,6 @@
"section_assignee": "执行方",
"select_agent": "选择智能体",
"select_assignee": "选择智能体或小队",
"section_project": "关联项目",
"no_project": "不关联项目",
"section_output_mode": "输出模式",
"section_schedule": "时间表",
"section_trigger_kind": "触发方式",

View File

@@ -117,7 +117,6 @@
"title_placeholder": "issue 标题",
"description_placeholder": "添加描述...",
"more_options_aria": "更多选项",
"title_required": "请输入标题后再创建",
"submit": "创建 issue",
"submitting": "创建中...",
"toast_created": "已创建 issue",

View File

@@ -3,11 +3,7 @@
"title": "项目",
"new_project": "新建项目",
"empty": "还没有项目",
"create_first": "创建第一个项目",
"view_compact": "紧凑视图",
"view_comfortable": "舒适视图",
"search_placeholder": "搜索项目...",
"no_search_results": "未找到匹配的项目"
"create_first": "创建第一个项目"
},
"table": {
"name": "名称",
@@ -61,7 +57,6 @@
"empty_issues_title": "还没有关联的 issue",
"empty_issues_hint": "新建一个 issue或把已有的 issue 关联到这个项目。",
"empty_issues_new_button": "新建 issue",
"no_issues_yet": "目前还没有 issue",
"toast_link_copied": "已复制链接",
"toast_project_deleted": "已删除项目",
"toast_move_issue_failed": "移动 issue 失败"

View File

@@ -96,6 +96,7 @@
"diagnostics_title": "诊断",
"diagnostics_cli": "CLI",
"diagnostics_visibility": "可见性",
"diagnostics_timezone": "时区",
"visibility_label": {
"private": "私有",
"public": "公开"
@@ -106,6 +107,10 @@
},
"visibility_toast_updated": "可见性已设为「{{visibility}}」",
"visibility_toast_failed": "更新可见性失败",
"timezone_browser_suffix": "(浏览器)",
"timezone_hint": "该 Runtime 的 Token 用量图表会按此时区进行日期分桶。",
"timezone_toast_updated": "时区已更新为 {{tz}}",
"timezone_toast_failed": "更新时区失败",
"delete_dialog": {
"title": "删除运行时",
"description": "确定要删除\"{{name}}\"吗?此操作不可撤销。",
@@ -146,42 +151,6 @@
"view_runtime": "查看运行时",
"create_agent": "创建智能体"
},
"cloud_runtime": {
"action": "Cloud Runtime",
"title": "Cloud Runtime",
"description": "创建托管云端节点,并查看它的 Fleet 状态。",
"create_title": "新节点",
"create_hint": "只需要填写名称、实例规格和磁盘大小,其余配置由 Fleet 默认处理。",
"nodes_title": "Fleet 节点",
"refresh": "刷新",
"nodes_empty": "还没有云端节点",
"nodes_failed": "Cloud Runtime 不可用",
"nodes_failed_hint": "Fleet 未返回节点列表。",
"node_fallback_name": "云端节点",
"create": "创建节点",
"creating": "创建中...",
"cancel": "取消",
"toast_created": "Cloud Runtime 节点已创建",
"toast_create_failed": "创建 Cloud Runtime 节点失败",
"fields": {
"name": "名称",
"instance_type": "实例规格",
"region": "Region",
"disk_size": "磁盘大小 GiB",
"image_id": "AMI ID",
"subnet_id": "Subnet ID",
"key_name": "Key pair",
"bootstrap_pat": "Bootstrap PAT"
},
"placeholders": {
"name": "cloud-dev-01",
"key_name": "dev-key"
},
"validation": {
"instance_type_required": "实例规格不能为空",
"disk_size_invalid": "磁盘大小必须是正整数"
}
},
"update": {
"cli_version_label": "CLI 版本:",
"version_unknown": "未知",

View File

@@ -11,12 +11,6 @@
"english": "English",
"chinese": "中文",
"sync_failed": "语言已在本设备保存,但同步到账号失败。其他设备可能仍显示旧语言。"
},
"timezone": {
"title": "查看时区",
"browser_suffix": "(浏览器)",
"hint": "用于仪表盘、图表和「今天」标签。这是个人偏好,在你的所有工作区中通用。",
"sync_failed": "保存时区偏好失败。"
}
},
"page": {

View File

@@ -9,13 +9,6 @@
"details_section": "详情",
"archive_button": "归档"
},
"archive_dialog": {
"title": "归档这个小队?",
"description": "“{{name}}” 将被归档,该小队当前承接的 issue 会转交给小队负责人。此操作无法撤销,如需恢复路由请新建小队。",
"cancel": "取消",
"confirm": "归档",
"archiving": "归档中…"
},
"name_editor": {
"cancel": "取消"
},

View File

@@ -226,7 +226,6 @@ vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@multica/ui/components/ui/button", () => ({

View File

@@ -29,7 +29,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@multica/ui/components/ui/tooltip";
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";
@@ -666,18 +666,9 @@ export function ManualCreatePanel({
/>
{t(($) => $.create_issue.create_another)}
</label>
{!title.trim() ? (
<TooltipProvider delay={200}>
<Tooltip>
<TooltipTrigger render={<span><Button size="sm" onClick={handleSubmit} disabled>{t(($) => $.create_issue.submit)}</Button></span>} />
<TooltipContent side="top">{t(($) => $.create_issue.title_required)}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button size="sm" onClick={handleSubmit} disabled={submitting}>
{submitting ? t(($) => $.create_issue.submitting) : t(($) => $.create_issue.submit)}
</Button>
)}
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? t(($) => $.create_issue.submitting) : t(($) => $.create_issue.submit)}
</Button>
</div>
</div>
</>

View File

@@ -1,79 +0,0 @@
"use client";
import { Check } from "lucide-react";
import {
PROJECT_STATUS_CONFIG,
PROJECT_STATUS_ORDER,
PROJECT_PRIORITY_CONFIG,
PROJECT_PRIORITY_ORDER
} from "@multica/core/projects/config";
import { cn } from "@multica/ui/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types";
import { PriorityIcon } from "../../issues/components/priority-icon";
import { useProjectStatusLabels, useProjectPriorityLabels } from "./labels";
export function ProjectStatusBadge({ project, handleUpdate, triggerClassName, align = "end" }: { project: Project; handleUpdate: (data: UpdateProjectRequest) => void; triggerClassName?: string; align?: "start" | "end" | "center" }) {
const statusLabels = useProjectStatusLabels();
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<button type="button" className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity",
statusCfg.badgeBg, statusCfg.badgeText,
triggerClassName
)}>
{statusLabels[project.status]}
</button>
}
/>
<DropdownMenuContent align={align} className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => handleUpdate({ status: s as ProjectStatus })}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{statusLabels[s]}</span>
{s === project.status && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function ProjectPriorityBadge({ project, handleUpdate, triggerClassName, align = "end" }: { project: Project; handleUpdate: (data: UpdateProjectRequest) => void; triggerClassName?: string; align?: "start" | "end" | "center" }) {
const priorityLabels = useProjectPriorityLabels();
const priorityCfg = PROJECT_PRIORITY_CONFIG[project.priority];
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<button type="button" className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium hover:bg-accent/60 transition-colors cursor-pointer",
triggerClassName
)}>
<PriorityIcon priority={project.priority} />
<span className={cn("text-xs", priorityCfg.color)}>{priorityLabels[project.priority]}</span>
</button>
}
/>
<DropdownMenuContent align={align} className="w-44">
{PROJECT_PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => handleUpdate({ priority: p as ProjectPriority })}>
<PriorityIcon priority={p} />
<span>{priorityLabels[p]}</span>
{p === project.priority && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,99 +0,0 @@
"use client";
import { useState } from "react";
import { UserMinus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { useActorName } from "@multica/core/workspace/hooks";
import { Popover, PopoverContent, PopoverTrigger } from "@multica/ui/components/ui/popover";
import type { Project, UpdateProjectRequest } from "@multica/core/types";
import { useT } from "../../i18n";
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
import { ActorAvatar } from "../../common/actor-avatar";
export function ProjectLeadPicker({ project, handleUpdate, renderTrigger, align = "start" }: {
project: Project;
handleUpdate: (data: UpdateProjectRequest) => void;
renderTrigger: (leadName: string | null) => React.ReactElement;
align?: "start" | "end" | "center"
}) {
const { t } = useT("projects");
const wsId = useWorkspaceId();
const { getActorName } = useActorName();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
const leadQuery = leadFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery) || matchesPinyin(m.name, leadQuery));
const filteredAgents = agents.filter((a) => !a.archived_at && (a.name.toLowerCase().includes(leadQuery) || matchesPinyin(a.name, leadQuery)));
const leadId = project.lead_id;
const leadType = project.lead_type;
const leadName = leadId && leadType ? getActorName(leadType, leadId) : null;
return (
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
<PopoverTrigger render={renderTrigger(leadName)} />
<PopoverContent align={align} className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={leadFilter}
onChange={(e) => setLeadFilter(e.target.value)}
placeholder={t(($) => $.lead.assign_placeholder)}
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => { handleUpdate({ lead_type: null, lead_id: null }); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">{t(($) => $.lead.no_lead)}</span>
</button>
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t(($) => $.lead.members_group)}</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => { handleUpdate({ lead_type: "member", lead_id: 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"
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
<span>{m.name}</span>
</button>
))}
</>
)}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t(($) => $.lead.agents_group)}</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => { handleUpdate({ lead_type: "agent", lead_id: 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"
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} showStatusDot />
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">{t(($) => $.lead.no_results)}</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,128 +1,69 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Plus, FolderKanban, Rows3, LayoutGrid, Search } from "lucide-react";
import { useState, useCallback } from "react";
import { Plus, FolderKanban, UserMinus, Check } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { projectListOptions } from "@multica/core/projects/queries";
import { useUpdateProject } from "@multica/core/projects/mutations";
import {
PROJECT_STATUS_CONFIG,
PROJECT_STATUS_ORDER,
PROJECT_PRIORITY_CONFIG,
PROJECT_PRIORITY_ORDER,
} from "@multica/core/projects/config";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useModalStore } from "@multica/core/modals";
import { AppLink } from "../../navigation";
import { ActorAvatar } from "../../common/actor-avatar";
import { useActorName } from "@multica/core/workspace/hooks";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { cn } from "@multica/ui/lib/utils";
import type { Project, UpdateProjectRequest } from "@multica/core/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types";
import { PageHeader } from "../../layout/page-header";
import { PriorityIcon } from "../../issues/components/priority-icon";
import { ProjectIcon } from "./project-icon";
import { useT } from "../../i18n";
import {
useProjectStatusLabels,
useProjectPriorityLabels,
useFormatRelativeDate,
} from "./labels";
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
import { useFormatRelativeDate } from "./labels";
import { useProjectViewStore } from "@multica/core/projects";
import { ProjectStatusBadge, ProjectPriorityBadge } from "./project-badge";
import { ProjectLeadPicker } from "./project-lead-picker";
const COMPACT_GRID = "grid w-full min-w-[740px] grid-cols-[24px_minmax(200px,1fr)_96px_96px_80px_80px_80px]";
function ProjectCard({ project }: { project: Project }) {
function ProjectRow({ project }: { project: Project }) {
const { t } = useT("projects");
const wsId = useWorkspaceId();
const wsPaths = useWorkspacePaths();
const statusLabels = useProjectStatusLabels();
const priorityLabels = useProjectPriorityLabels();
const formatRelativeDate = useFormatRelativeDate();
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
const priorityCfg = PROJECT_PRIORITY_CONFIG[project.priority];
const updateProject = useUpdateProject();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const handleUpdate = useCallback(
(data: UpdateProjectRequest) => {
updateProject.mutate({ id: project.id, ...data });
},
[project.id, updateProject],
);
const progressPercent = project.issue_count > 0 ? Math.round((project.done_count / project.issue_count) * 100) : 0;
return (
<div className="group/card flex flex-col rounded-md border bg-card hover:border-primary/50 transition-colors">
<div className="p-3 pb-2">
<div className="flex items-center gap-2">
<AppLink
href={wsPaths.projectDetail(project.id)}
className="flex items-center gap-2 min-w-0 flex-1"
>
<ProjectIcon project={project} size="sm" />
<h3 className="font-medium text-sm truncate">{project.title}</h3>
</AppLink>
<ProjectStatusBadge project={project} handleUpdate={handleUpdate} triggerClassName="shrink-0" />
</div>
{project.issue_count > 0 ? (
<div className="flex justify-end items-center gap-1.5 pt-2">
<div className="relative h-4 w-4">
<svg className="h-4 w-4 -rotate-90" viewBox="0 0 16 16">
<circle
className="text-muted"
strokeWidth="2"
stroke="currentColor"
fill="none"
r="6"
cx="8"
cy="8"
/>
<circle
className="text-emerald-500"
strokeWidth="2"
stroke="currentColor"
fill="none"
r="6"
cx="8"
cy="8"
strokeDasharray={`${progressPercent * 0.377} 37.7`}
strokeLinecap="round"
/>
</svg>
</div>
<span className="text-[10px] text-muted-foreground tabular-nums">
{project.done_count}/{project.issue_count}
</span>
</div>
) : (
<span className="text-[10px] text-muted-foreground pt-2 flex justify-end">{t(($) => $.detail.no_issues_yet)}</span>
)}
</div>
<div className="flex items-center justify-between px-3 pb-3 border-t mt-0 pt-2">
<ProjectLeadPicker
project={project}
handleUpdate={handleUpdate}
renderTrigger={(leadName) => (
<button type="button" className="flex items-center gap-1.5 rounded px-1.5 py-0.5 -mx-1.5 hover:bg-accent/60 transition-colors cursor-pointer">
{project.lead_type && project.lead_id ? (
<ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size={20} enableHoverCard />
) : (
<span className="inline-flex h-5 w-5 rounded-full border border-dashed border-muted-foreground/30" />
)}
<span className="text-[10px] text-muted-foreground truncate max-w-[60px]">
{leadName ?? t(($) => $.lead.no_lead)}
</span>
</button>
)}
/>
<div className="flex items-center gap-2">
<ProjectPriorityBadge project={project} handleUpdate={handleUpdate} align="start" />
<span className="text-[10px] text-muted-foreground">
{formatRelativeDate(project.created_at)}
</span>
</div>
</div>
</div>
);
}
function ProjectCardCompact({ project }: { project: Project }) {
const wsPaths = useWorkspacePaths();
const formatRelativeDate = useFormatRelativeDate();
const updateProject = useUpdateProject();
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
const leadQuery = leadFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery) || matchesPinyin(m.name, leadQuery));
const filteredAgents = agents.filter((a) => !a.archived_at && (a.name.toLowerCase().includes(leadQuery) || matchesPinyin(a.name, leadQuery)));
const handleUpdate = useCallback(
(data: UpdateProjectRequest) => {
@@ -132,74 +73,171 @@ function ProjectCardCompact({ project }: { project: Project }) {
);
return (
<div className={cn(COMPACT_GRID, "h-10 items-center gap-2 px-4 text-sm transition-colors hover:bg-accent/40 border-b")}>
<ProjectIcon project={project} size="sm" />
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
{/* Icon + Name (navigates to detail) */}
<AppLink
href={wsPaths.projectDetail(project.id)}
className="flex items-center justify-start gap-2 min-w-0 overflow-hidden"
className="flex min-w-0 flex-1 items-center gap-2"
>
<span className="font-medium truncate text-left">{project.title}</span>
<ProjectIcon project={project} size="md" />
<span className="min-w-0 flex-1 truncate font-medium">{project.title}</span>
</AppLink>
<div className="flex items-center justify-start">
<ProjectPriorityBadge project={project} handleUpdate={handleUpdate} align="start" />
</div>
{/* Priority — dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<button type="button" className="flex w-24 items-center justify-center gap-1 shrink-0 rounded px-1 py-0.5 hover:bg-accent/60 transition-colors cursor-pointer">
<PriorityIcon priority={project.priority} />
<span className={cn("text-xs", priorityCfg.color)}>{priorityLabels[project.priority]}</span>
</button>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => handleUpdate({ priority: p as ProjectPriority })}>
<PriorityIcon priority={p} />
<span>{priorityLabels[p]}</span>
{p === project.priority && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-start">
<ProjectStatusBadge project={project} handleUpdate={handleUpdate} align="start" />
</div>
{/* Status — dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<button type="button" className={cn(
"inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium shrink-0 w-28 justify-center cursor-pointer hover:opacity-80 transition-opacity",
statusCfg.badgeBg, statusCfg.badgeText,
)}>
{statusLabels[project.status]}
</button>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => handleUpdate({ status: s as ProjectStatus })}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{statusLabels[s]}</span>
{s === project.status && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<span className="flex items-center justify-start gap-1.5 text-xs text-muted-foreground tabular-nums">
{project.issue_count > 0 ? `${project.done_count}/${project.issue_count}` : "--"}
{/* Progress (read-only) */}
<span className="flex w-24 items-center justify-center gap-1.5 shrink-0">
{project.issue_count > 0 ? (
<>
<span className="relative h-1.5 w-12 rounded-full bg-muted overflow-hidden">
<span
className="absolute inset-y-0 left-0 rounded-full bg-emerald-500 transition-all"
style={{ width: `${Math.round((project.done_count / project.issue_count) * 100)}%` }}
/>
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{project.done_count}/{project.issue_count}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</span>
<ProjectLeadPicker
project={project}
handleUpdate={handleUpdate}
align="start"
renderTrigger={(leadName) => (
<button type="button" className="flex items-center justify-start gap-1.5 rounded px-1 py-0.5 hover:bg-accent/60 transition-colors cursor-pointer">
<span className="shrink-0">
{/* Lead — popover */}
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
<PopoverTrigger
render={
<button type="button" className="flex w-10 items-center justify-center shrink-0 rounded-full hover:ring-2 hover:ring-accent transition-all cursor-pointer">
{project.lead_type && project.lead_id ? (
<ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size={20} enableHoverCard />
<Tooltip>
<TooltipTrigger render={<span><ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size={22} enableHoverCard /></span>} />
<TooltipContent side="bottom">{getActorName(project.lead_type, project.lead_id)}</TooltipContent>
</Tooltip>
) : (
<span className="inline-flex h-5 w-5 rounded-full border border-dashed border-muted-foreground/30" />
<span className="h-[22px] w-[22px] rounded-full border border-dashed border-muted-foreground/30" />
)}
</span>
<span className="text-xs text-muted-foreground truncate max-w-[50px]">
{leadName ?? "--"}
</span>
</button>
)}
/>
</button>
}
/>
<PopoverContent align="start" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={leadFilter}
onChange={(e) => setLeadFilter(e.target.value)}
placeholder={t(($) => $.lead.assign_placeholder)}
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => { handleUpdate({ lead_type: null, lead_id: null }); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">{t(($) => $.lead.no_lead)}</span>
</button>
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t(($) => $.lead.members_group)}</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => { handleUpdate({ lead_type: "member", lead_id: 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"
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
<span>{m.name}</span>
</button>
))}
</>
)}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t(($) => $.lead.agents_group)}</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => { handleUpdate({ lead_type: "agent", lead_id: 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"
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} showStatusDot />
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">{t(($) => $.lead.no_results)}</div>
)}
</div>
</PopoverContent>
</Popover>
<span className="text-left text-xs text-muted-foreground tabular-nums">
{/* Created */}
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatRelativeDate(project.created_at)}
</span>
</div>
);
}
export function ProjectsPage() {
const { t } = useT("projects");
const wsId = useWorkspaceId();
const viewMode = useProjectViewStore((s) => s.viewMode);
const setViewMode = useProjectViewStore((s) => s.setViewMode);
const isCompact = viewMode === "compact";
const { data: projects = [], isLoading } = useQuery(projectListOptions(wsId));
const openCreateProject = () => useModalStore.getState().open("create-project");
const [search, setSearch] = useState("");
const filteredProjects = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return projects;
return projects.filter((p) =>
p.title.toLowerCase().includes(q) || matchesPinyin(p.title, q)
);
}, [projects, search]);
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-full flex-col">
{/* Header bar */}
<PageHeader className="justify-between px-5">
<div className="flex items-center gap-2">
<FolderKanban className="h-4 w-4 text-muted-foreground" />
@@ -214,128 +252,52 @@ export function ProjectsPage() {
</Button>
</PageHeader>
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
{(projects.length > 0 || isLoading) && (
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4 gap-2 sm:gap-3">
<div className="relative flex-1 sm:flex-none">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t(($) => $.page.search_placeholder)}
className="h-8 w-full sm:w-64 pl-8 text-sm"
/>
{/* Table */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<>
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
<span className="shrink-0 w-[24px]" />
<Skeleton className="h-3 w-12 flex-1 max-w-[48px]" />
<Skeleton className="h-3 w-12 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
<Skeleton className="h-3 w-8 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
</div>
<div className="flex items-center gap-2 sm:gap-4 shrink-0">
<span className="hidden sm:inline-block font-mono text-xs tabular-nums text-muted-foreground/70">
{filteredProjects.length} / {projects.length}
</span>
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<button
type="button"
onClick={() => setViewMode("compact")}
className={cn(
"inline-flex items-center gap-1.5 rounded p-1 sm:px-2.5 sm:py-1 text-xs font-medium transition-colors",
isCompact ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
<Rows3 className="size-3.5" />
<span className="hidden sm:inline-block">{t(($) => $.page.view_compact)}</span>
</button>
<button
type="button"
onClick={() => setViewMode("comfortable")}
className={cn(
"inline-flex items-center gap-1.5 rounded p-1 sm:px-2.5 sm:py-1 text-xs font-medium transition-colors",
!isCompact ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
<LayoutGrid className="size-3.5" />
<span className="hidden sm:inline-block">{t(($) => $.page.view_comfortable)}</span>
</button>
</div>
</div>
</div>
)}
<div key={viewMode} className={cn("flex-1", isCompact ? "overflow-hidden flex flex-col" : "overflow-y-auto")}>
{isLoading ? (
isCompact ? (
<div className="pt-4 mx-5 overflow-x-auto rounded-md border pb-4 mb-5">
<div className="min-w-[740px]">
<div className={cn(COMPACT_GRID, "h-10 items-center gap-2 px-4 border-b")}>
<Skeleton className="h-6 w-6 rounded" />
<Skeleton className="h-4 w-48" />
</div>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={cn(COMPACT_GRID, "h-10 items-center gap-2 px-4 border-b")}>
<Skeleton className="h-6 w-6 rounded" />
<Skeleton className="h-4 w-48" />
</div>
))}
</div>
</div>
) : (
<div className="pt-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 px-5">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex flex-col rounded-md border p-3 gap-2">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="flex gap-1.5">
<Skeleton className="h-5 w-16 rounded" />
<Skeleton className="h-5 w-20 rounded" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-3 w-12" />
</div>
</div>
))}
</div>
)
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<FolderKanban className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">{t(($) => $.page.empty)}</p>
<Button size="sm" variant="outline" className="mt-3" onClick={openCreateProject}>
{t(($) => $.page.create_first)}
</Button>
</div>
) : filteredProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<Search className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">{t(($) => $.page.no_search_results)}</p>
</div>
) : isCompact ? (
<div className="mt-4 mx-5 rounded-md border mb-5 overflow-auto flex-1">
<div className="min-w-[740px]">
<div className={cn(COMPACT_GRID, "h-8 shrink-0 items-center gap-2 px-4 text-xs font-medium text-muted-foreground border-b bg-muted/30 sticky top-0 z-10")}>
<span />
<span className="text-left">{t(($) => $.table.name)}</span>
<span className="text-left">{t(($) => $.table.priority)}</span>
<span className="text-left">{t(($) => $.table.status)}</span>
<span className="text-left">{t(($) => $.table.progress)}</span>
<span className="text-left">{t(($) => $.table.lead)}</span>
<span className="text-left">{t(($) => $.table.created)}</span>
</div>
<div className="pb-4">
{filteredProjects.map((project) => (
<ProjectCardCompact key={project.id} project={project} />
))}
</div>
</div>
</div>
) : (
<div className="pt-4 pb-5 px-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{filteredProjects.map((project) => (
<ProjectCard key={project.id} project={project} />
<div className="p-5 pt-1 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
)}
</div>
</>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<FolderKanban className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">{t(($) => $.page.empty)}</p>
<Button size="sm" variant="outline" className="mt-3" onClick={openCreateProject}>
{t(($) => $.page.create_first)}
</Button>
</div>
) : (
<>
{/* Column headers */}
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
{/* Icon spacer + Name */}
<span className="shrink-0 w-[24px]" />
<span className="min-w-0 flex-1">{t(($) => $.table.name)}</span>
<span className="w-24 text-center shrink-0">{t(($) => $.table.priority)}</span>
<span className="w-28 text-center shrink-0">{t(($) => $.table.status)}</span>
<span className="w-24 text-center shrink-0">{t(($) => $.table.progress)}</span>
<span className="w-10 text-center shrink-0">{t(($) => $.table.lead)}</span>
<span className="w-20 text-right shrink-0">{t(($) => $.table.created)}</span>
</div>
{/* Rows */}
{projects.map((project) => (
<ProjectRow key={project.id} project={project} />
))}
</>
)}
</div>
</div>
);

View File

@@ -71,7 +71,7 @@ export function ActivityHeatmap({
}
// Anchor the grid on the Monday of the week containing "today" in the
// viewer's tz, then walk back HEATMAP_WEEKS-1 weeks. All dates are
// runtime's tz, then walk back HEATMAP_WEEKS-1 weeks. All dates are
// string-based YYYY-MM-DD so the host browser's tz can't shift a column.
// We stop drawing cells once we pass `today` so the in-progress week is
// partial (cells for "tomorrow onward" aren't rendered) — matches the

View File

@@ -1,373 +0,0 @@
"use client";
import { useId, useMemo, useState } from "react";
import type { FormEvent, HTMLAttributes } from "react";
import { useQuery } from "@tanstack/react-query";
import { Cloud, Loader2, RefreshCw, Rocket } from "lucide-react";
import { toast } from "sonner";
import type { CloudRuntimeNode } from "@multica/core/runtimes";
import {
cloudRuntimeNodeListOptions,
useCreateCloudRuntimeNode,
} from "@multica/core/runtimes";
import { useWorkspaceId } from "@multica/core/hooks";
import { Badge } from "@multica/ui/components/ui/badge";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@multica/ui/components/ui/select";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
const CLOUD_RUNTIME_INSTANCE_TYPES = ["t4g.medium", "t4g.large"] as const;
const DEFAULT_INSTANCE_TYPE = CLOUD_RUNTIME_INSTANCE_TYPES[0];
const DEFAULT_DISK_SIZE_GB = 20;
export function CloudRuntimeDialog({ onClose }: { onClose: () => void }) {
const { t } = useT("runtimes");
const wsId = useWorkspaceId();
const idPrefix = `cloud-runtime-${useId().replace(/:/g, "")}`;
const formId = `${idPrefix}-form`;
const [name, setName] = useState("");
const [instanceType, setInstanceType] = useState<string>(
DEFAULT_INSTANCE_TYPE,
);
const [diskSizeGB, setDiskSizeGB] = useState(String(DEFAULT_DISK_SIZE_GB));
const nodesQuery = useQuery(
cloudRuntimeNodeListOptions(wsId, { limit: 20, offset: 0 }),
);
const createNode = useCreateCloudRuntimeNode(wsId);
const sortedNodes = useMemo(
() =>
[...(nodesQuery.data ?? [])].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
[nodesQuery.data],
);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const diskSize = diskSizeGB.trim()
? Number(diskSizeGB.trim())
: DEFAULT_DISK_SIZE_GB;
if (!Number.isInteger(diskSize) || diskSize <= 0) {
toast.error(t(($) => $.cloud_runtime.validation.disk_size_invalid));
return;
}
try {
await createNode.mutateAsync({
data: {
instance_type: instanceType,
name: valueOrUndefined(name),
disk_size_gb: diskSize,
},
});
toast.success(t(($) => $.cloud_runtime.toast_created));
setName("");
setInstanceType(DEFAULT_INSTANCE_TYPE);
setDiskSizeGB(String(DEFAULT_DISK_SIZE_GB));
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t(($) => $.cloud_runtime.toast_create_failed),
);
}
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex max-h-[88vh] flex-col gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="border-b px-6 py-5">
<DialogTitle className="flex items-center gap-2 text-base">
<Cloud className="h-4 w-4 text-muted-foreground" />
{t(($) => $.cloud_runtime.title)}
</DialogTitle>
<DialogDescription className="text-xs">
{t(($) => $.cloud_runtime.description)}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(280px,0.82fr)]">
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div>
<h3 className="text-sm font-medium">
{t(($) => $.cloud_runtime.create_title)}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{t(($) => $.cloud_runtime.create_hint)}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<LabeledInput
id={`${idPrefix}-name`}
label={t(($) => $.cloud_runtime.fields.name)}
value={name}
onChange={setName}
placeholder={t(($) => $.cloud_runtime.placeholders.name)}
/>
<LabeledInput
id={`${idPrefix}-instance-type`}
label={t(($) => $.cloud_runtime.fields.instance_type)}
value={instanceType}
onChange={setInstanceType}
options={CLOUD_RUNTIME_INSTANCE_TYPES}
/>
<LabeledInput
id={`${idPrefix}-disk-size`}
label={t(($) => $.cloud_runtime.fields.disk_size)}
value={diskSizeGB}
onChange={setDiskSizeGB}
placeholder={String(DEFAULT_DISK_SIZE_GB)}
type="number"
inputMode="numeric"
/>
</div>
</form>
<section className="min-h-0 rounded-md border bg-muted/20">
<div className="flex items-center justify-between border-b bg-background px-3 py-2.5">
<h3 className="text-sm font-medium">
{t(($) => $.cloud_runtime.nodes_title)}
</h3>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void nodesQuery.refetch()}
disabled={nodesQuery.isFetching}
className="h-7 px-2"
>
<RefreshCw
className={cn(
"h-3.5 w-3.5",
nodesQuery.isFetching && "animate-spin",
)}
/>
{t(($) => $.cloud_runtime.refresh)}
</Button>
</div>
{nodesQuery.isLoading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : nodesQuery.isError ? (
<div className="flex h-40 flex-col items-center justify-center px-5 text-center">
<p className="text-sm font-medium">
{t(($) => $.cloud_runtime.nodes_failed)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{nodesQuery.error instanceof Error
? nodesQuery.error.message
: t(($) => $.cloud_runtime.nodes_failed_hint)}
</p>
</div>
) : sortedNodes.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center px-5 text-center">
<Cloud className="h-7 w-7 text-muted-foreground/50" />
<p className="mt-3 text-sm font-medium">
{t(($) => $.cloud_runtime.nodes_empty)}
</p>
</div>
) : (
<div className="max-h-[410px] overflow-y-auto p-2">
<div className="space-y-2">
{sortedNodes.map((node) => (
<CloudRuntimeNodeRow key={node.id} node={node} />
))}
</div>
</div>
)}
</section>
</div>
</div>
<DialogFooter className="m-0 border-t bg-muted/30 px-6 py-3">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
{t(($) => $.cloud_runtime.cancel)}
</Button>
<Button
type="submit"
size="sm"
form={formId}
disabled={createNode.isPending}
>
{createNode.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Rocket className="h-3.5 w-3.5" />
)}
{createNode.isPending
? t(($) => $.cloud_runtime.creating)
: t(($) => $.cloud_runtime.create)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function LabeledInput({
id,
label,
value,
onChange,
placeholder,
required,
type = "text",
inputMode,
options,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
type?: string;
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
options?: readonly string[];
}) {
if (options) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs text-muted-foreground">
{label}
</Label>
<Select value={value} onValueChange={(next) => onChange(next ?? value)}>
<SelectTrigger id={id} className="h-9 w-full rounded-md text-sm">
<SelectValue>
{() => <span className="truncate">{value}</span>}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs text-muted-foreground">
{label}
</Label>
<Input
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
required={required}
type={type}
inputMode={inputMode}
className="h-9 text-sm"
/>
</div>
);
}
function CloudRuntimeNodeRow({ node }: { node: CloudRuntimeNode }) {
const { t } = useT("runtimes");
const title =
node.name.trim() ||
node.instance_id.trim() ||
t(($) => $.cloud_runtime.node_fallback_name);
const created = formatDateTime(node.created_at);
return (
<div className="rounded-md border bg-background px-3 py-2.5">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{title}</span>
<CloudRuntimeStatusBadge status={node.status} />
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{node.instance_type}</span>
<span className="text-muted-foreground/40">/</span>
<span>{node.region}</span>
{created && (
<>
<span className="text-muted-foreground/40">/</span>
<span>{created}</span>
</>
)}
</div>
</div>
</div>
{node.instance_id && (
<div className="mt-2 truncate font-mono text-[11px] text-muted-foreground/80">
{node.instance_id}
</div>
)}
</div>
);
}
function CloudRuntimeStatusBadge({ status }: { status: string }) {
const normalized = status.toLowerCase();
const active = new Set(["running", "success"]);
const pending = new Set([
"launching",
"pending",
"starting",
"stopping",
"rebooting",
"terminating",
]);
const failed = new Set(["failed", "terminated", "error"]);
return (
<Badge
variant="secondary"
className={cn(
"h-5 rounded-md px-1.5 font-mono text-[10px]",
active.has(normalized) && "bg-success/10 text-success",
pending.has(normalized) && "bg-warning/10 text-warning",
failed.has(normalized) && "bg-destructive/10 text-destructive",
)}
>
{status || "unknown"}
</Badge>
);
}
function valueOrUndefined(value: string): string | undefined {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function formatDateTime(value: string): string | null {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}

View File

@@ -40,7 +40,6 @@ import {
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { useViewingTimezone } from "../../common/use-viewing-timezone";
import { workloadConfig } from "../../agents/presence";
import { ProviderLogo } from "./provider-logo";
import { HealthIcon, useHealthLabel } from "./shared";
@@ -334,17 +333,13 @@ const COST_CELL_DAYS = 14;
function CostCell({ runtimeId }: { runtimeId: string }) {
const { t } = useT("runtimes");
const tz = useViewingTimezone();
const { data: usage = [] } = useQuery(
runtimeUsageOptions(runtimeId, COST_CELL_DAYS, tz),
);
const cost7d = useMemo(
() => computeCostInWindow(usage, 7, tz),
[usage, tz],
runtimeUsageOptions(runtimeId, COST_CELL_DAYS),
);
const cost7d = useMemo(() => computeCostInWindow(usage, 7), [usage]);
const costPrev7d = useMemo(
() => computeCostInWindow(usage, 7, tz, 7),
[usage, tz],
() => computeCostInWindow(usage, 7, 7),
[usage],
);
const delta = pctChange(cost7d, costPrev7d);

View File

@@ -112,6 +112,7 @@ function makeRuntime(overrides: Partial<AgentRuntime>): AgentRuntime {
metadata: {},
owner_id: "user-me",
visibility: "private",
timezone: "UTC",
last_seen_at: "2026-04-27T11:59:50Z",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",

View File

@@ -39,6 +39,7 @@ import {
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { TimezoneSelect } from "../../common/timezone-select";
import { AppLink } from "../../navigation";
import { availabilityConfig, workloadConfig } from "../../agents/presence";
import { formatLastSeen } from "../utils";
@@ -519,6 +520,16 @@ function DiagnosticsCard({
<VisibilityReadout runtime={runtime} />
)}
</div>
<div className="border-t pt-3">
<div className="mb-1.5 text-[11px] uppercase tracking-wide text-muted-foreground">
{t(($) => $.detail.diagnostics_timezone)}
</div>
{canDelete ? (
<TimezoneEditor runtime={runtime} />
) : (
<TimezoneReadout runtime={runtime} />
)}
</div>
{isLocal && (
<div className="border-t pt-3">
<div className="mb-1.5 text-[11px] uppercase tracking-wide text-muted-foreground">
@@ -670,3 +681,59 @@ function VisibilityChoice({
</Tooltip>
);
}
function TimezoneReadout({ runtime }: { runtime: AgentRuntime }) {
const { t } = useT("runtimes");
return (
<div className="space-y-1.5">
<div className="rounded-md border bg-muted/30 px-2 py-1.5 font-mono text-xs">
{runtime.timezone || "UTC"}
</div>
<p className="text-[11px] leading-snug text-muted-foreground">
{t(($) => $.detail.timezone_hint)}
</p>
</div>
);
}
// TimezoneEditor renders the current runtime tz, a dropdown of supported IANA
// zones (plus the runtime's current value if it is unusual), and commits the
// change via PATCH /api/runtimes/:id. We deliberately don't gate this behind a
// separate "edit" mode because the change is reversible.
function TimezoneEditor({ runtime }: { runtime: AgentRuntime }) {
const { t } = useT("runtimes");
const wsId = useWorkspaceId();
const updateRuntime = useUpdateRuntime(wsId);
const current = runtime.timezone || "UTC";
const handleTimezoneChange = (next: string) => {
if (next === current) return;
updateRuntime.mutate(
{ runtimeId: runtime.id, patch: { timezone: next } },
{
onSuccess: () =>
toast.success(t(($) => $.detail.timezone_toast_updated, { tz: next })),
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.detail.timezone_toast_failed),
),
},
);
};
return (
<div className="space-y-1.5">
<TimezoneSelect
value={current}
onValueChange={handleTimezoneChange}
browserSuffix={t(($) => $.detail.timezone_browser_suffix)}
disabled={updateRuntime.isPending}
/>
<p className="text-[11px] leading-snug text-muted-foreground">
{t(($) => $.detail.timezone_hint)}
</p>
</div>
);
}

View File

@@ -23,6 +23,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
metadata: { cli_version: "0.3.0" },
owner_id: "user-1",
visibility: "private",
timezone: "UTC",
last_seen_at: new Date(NOW - 10_000).toISOString(),
created_at: "2026-05-17T11:00:00Z",
updated_at: "2026-05-17T11:00:00Z",
@@ -105,58 +106,6 @@ describe("runtime machine grouping", () => {
expect(subtitle).toMatch(/^daemon /);
});
it("synthesizes a placeholder local machine when ensureLocalMachine is set and no runtime matches", () => {
// Reproduces the "Start button disappears after stopping the daemon"
// bug: the daemon is stopped (localDaemonId is null) and the server
// has already GC'd the local runtime, so no machine ends up flagged
// isCurrent. Without synthesis the local row vanishes and the
// Start button has nowhere to render.
const machines = buildRuntimeMachines(
[
makeRuntime({
id: "rt-remote",
daemon_id: "daemon-remote",
name: "Claude (remote.box)",
device_info: "remote.box",
}),
],
{
now: NOW,
localDaemonId: null,
localMachineName: "My Laptop",
ensureLocalMachine: true,
},
);
expect(machines).toHaveLength(2);
const local = machines.find((m) => m.isCurrent);
expect(local).toMatchObject({
title: "My Laptop",
section: "local",
isCurrent: true,
runtimes: [],
});
});
it("does not synthesize a placeholder when a real local runtime exists", () => {
const machines = buildRuntimeMachines(
[makeRuntime({ daemon_id: "daemon-1" })],
{
now: NOW,
localDaemonId: "daemon-1",
ensureLocalMachine: true,
},
);
expect(machines).toHaveLength(1);
expect(machines[0]).toMatchObject({
isCurrent: true,
runtimes: expect.arrayContaining([
expect.objectContaining({ daemon_id: "daemon-1" }),
]),
});
});
it("keeps cloud runtimes as cloud workers when they have no daemon", () => {
const machines = buildRuntimeMachines(
[

View File

@@ -35,15 +35,6 @@ interface RuntimeMachineOptions {
localDaemonId?: string | null;
localMachineName?: string | null;
workloadByRuntimeId?: Map<string, RuntimeWorkloadSummary>;
/**
* When true, guarantee that the result contains a machine flagged
* `isCurrent`. If no server-side runtime matches the local daemon
* (e.g. the daemon is stopped, was never started, or its runtime was
* already GC'd), a placeholder local machine is synthesized so the
* caller can still attach controls to it (Start button, etc.).
* Desktop sets this; web omits it.
*/
ensureLocalMachine?: boolean;
}
interface RuntimeMachineDraft {
@@ -89,40 +80,9 @@ export function buildRuntimeMachines(
drafts.set(id, draft);
}
const machines = Array.from(drafts.values()).map((draft) =>
finalizeRuntimeMachine(draft, options),
);
if (options.ensureLocalMachine && !machines.some((m) => m.isCurrent)) {
machines.push(placeholderLocalMachine(options));
}
return machines.sort(compareRuntimeMachines);
}
function placeholderLocalMachine(
options: RuntimeMachineOptions,
): RuntimeMachine {
const daemonId = options.localDaemonId ?? null;
return {
id: daemonId ? `local:${daemonId}` : "local:placeholder",
daemonId,
title: options.localMachineName ?? "This machine",
subtitle: null,
deviceInfo: null,
cliVersion: null,
mode: "local",
section: "local",
isCurrent: true,
health: "offline",
runtimes: [],
onlineCount: 0,
issueCount: 0,
runningCount: 0,
queuedCount: 0,
providerNames: [],
lastSeenAt: null,
};
return Array.from(drafts.values())
.map((draft) => finalizeRuntimeMachine(draft, options))
.sort(compareRuntimeMachines);
}
export function filterRuntimeMachines(

View File

@@ -29,7 +29,6 @@ import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { cn } from "@multica/ui/lib/utils";
import { PageHeader } from "../../layout/page-header";
import { ConnectRemoteDialog } from "./connect-remote-dialog";
import { CloudRuntimeDialog } from "./cloud-runtime-dialog";
import { ProviderLogo } from "./provider-logo";
import { RuntimeList, buildWorkloadIndex } from "./runtime-list";
import {
@@ -51,14 +50,6 @@ interface RuntimesPageProps {
localMachineName?: string | null;
/** Desktop-only controls shown when the local machine is selected. */
localMachineActions?: React.ReactNode;
/**
* Desktop-only signal: this host always owns a local machine, even
* when no runtime is currently registered (daemon stopped, not yet
* started, or runtime GC'd). When true, a placeholder local row is
* synthesized so `localMachineActions` (the daemon Start button) is
* always reachable. Web omits this.
*/
hasLocalMachine?: boolean;
/**
* Desktop-only signal: the bundled daemon is still booting / hasn't
* registered with the server yet. Forwarded so the empty state can show
@@ -66,8 +57,6 @@ interface RuntimesPageProps {
* during the boot window. Web omits this.
*/
bootstrapping?: boolean;
/** Web SaaS-only Cloud Runtime entrypoint. Defaults off for self-hosted builds. */
cloudRuntimeEnabled?: boolean;
}
// Re-render every 30s so derived health (recently_lost → offline transitions)
@@ -85,9 +74,7 @@ export function RuntimesPage({
localDaemonId,
localMachineName,
localMachineActions,
hasLocalMachine,
bootstrapping,
cloudRuntimeEnabled = false,
}: RuntimesPageProps = {}) {
const isLoading = useAuthStore((s) => s.isLoading);
const wsId = useWorkspaceId();
@@ -107,7 +94,6 @@ export function RuntimesPage({
setSelectedMachineId(id);
}, []);
const [showConnectDialog, setShowConnectDialog] = useState(false);
const [showCloudRuntimeDialog, setShowCloudRuntimeDialog] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_runtimes_layout",
});
@@ -139,16 +125,8 @@ export function RuntimesPage({
localDaemonId,
localMachineName,
workloadByRuntimeId: workloadIndex,
ensureLocalMachine: hasLocalMachine,
}),
[
runtimes,
now,
localDaemonId,
localMachineName,
workloadIndex,
hasLocalMachine,
],
[runtimes, now, localDaemonId, localMachineName, workloadIndex],
);
const machineCounts = useMemo(() => runtimeMachineCounts(machines), [machines]);
@@ -183,17 +161,13 @@ export function RuntimesPage({
if (isLoading || fetching) return <RuntimesPageSkeleton />;
const totalCount = runtimes.length;
// Desktop always has a synthesized local machine row, so the
// "register a runtime" empty state would hide the Start button.
const showEmpty = totalCount === 0 && !bootstrapping && !hasLocalMachine;
const showEmpty = totalCount === 0 && !bootstrapping;
return (
<div className="flex flex-1 min-h-0 flex-col">
<PageHeaderBar
totalCount={totalCount}
onConnectRemote={() => setShowConnectDialog(true)}
cloudRuntimeEnabled={cloudRuntimeEnabled}
onOpenCloudRuntime={() => setShowCloudRuntimeDialog(true)}
/>
{showEmpty ? (
@@ -270,9 +244,6 @@ export function RuntimesPage({
{showConnectDialog && (
<ConnectRemoteDialog onClose={() => setShowConnectDialog(false)} />
)}
{cloudRuntimeEnabled && showCloudRuntimeDialog && (
<CloudRuntimeDialog onClose={() => setShowCloudRuntimeDialog(false)} />
)}
</div>
);
}
@@ -285,13 +256,9 @@ export function RuntimesPage({
function PageHeaderBar({
totalCount,
onConnectRemote,
cloudRuntimeEnabled,
onOpenCloudRuntime,
}: {
totalCount: number;
onConnectRemote: () => void;
cloudRuntimeEnabled: boolean;
onOpenCloudRuntime: () => void;
}) {
const { t } = useT("runtimes");
return (
@@ -305,23 +272,10 @@ function PageHeaderBar({
</span>
)}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{cloudRuntimeEnabled && (
<Button
type="button"
size="sm"
variant="outline"
onClick={onOpenCloudRuntime}
>
<Cloud className="h-3 w-3" />
{t(($) => $.cloud_runtime.action)}
</Button>
)}
<Button type="button" size="sm" onClick={onConnectRemote}>
<Plus className="h-3 w-3" />
{t(($) => $.page.connect_remote)}
</Button>
</div>
<Button type="button" size="sm" onClick={onConnectRemote}>
<Plus className="h-3 w-3" />
{t(($) => $.page.connect_remote)}
</Button>
</PageHeader>
);
}

View File

@@ -1,149 +0,0 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { AgentRuntime } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enRuntimes from "../../locales/en/runtimes.json";
const TEST_RESOURCES = { en: { common: enCommon, runtimes: enRuntimes } };
// The viewer's tz (Viewing layer) drives both the trend and the heatmap.
const VIEWER_TZ = "Asia/Tokyo";
// runtimeUsageOptions is the trend-fetch query. Capture its args so the
// test can assert which tz the trend was wired with.
const runtimeUsageOptions = vi.hoisted(() =>
vi.fn((..._args: unknown[]) => ({ kind: "usage" as const })),
);
const runtimeUsageByAgentOptions = vi.hoisted(() =>
vi.fn((..._args: unknown[]) => ({ kind: "by-agent" as const })),
);
vi.mock("../../common/use-viewing-timezone", () => ({
useViewingTimezone: () => VIEWER_TZ,
}));
vi.mock("@multica/core/runtimes/queries", () => ({
runtimeUsageOptions,
runtimeUsageByAgentOptions,
}));
vi.mock("@multica/core/workspace/queries", () => ({
agentListOptions: () => ({ kind: "agents" as const }),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
// custom-pricing-store is consumed two ways: usage-section reads the store
// hook, and runtimes/utils reads getCustomPricing(). The hook must be both
// callable and expose getState(), mirroring a real Zustand store.
vi.mock("@multica/core/runtimes/custom-pricing-store", () => {
const state = { pricings: {} as Record<string, unknown> };
const useCustomPricingStore = Object.assign(
(sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state),
{ getState: () => state },
);
return { useCustomPricingStore, getCustomPricing: () => undefined };
});
// useQuery is mocked so the component renders synchronously with canned
// data — the `kind` tag on each query-options object routes the response.
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
const usageRows = [
{
runtime_id: "r-1",
date: "2026-05-19",
provider: "anthropic",
model: "claude-sonnet-4-6",
input_tokens: 1_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
];
return {
...actual,
useQuery: (opts: { kind?: string }) => ({
data: opts?.kind === "usage" ? usageRows : [],
isLoading: false,
}),
};
});
// Charts are recharts-heavy; stub them. ActivityHeatmap echoes its `tz`
// prop so the test can read which tz the heatmap was wired with.
vi.mock("./charts", () => ({
DailyCostChart: () => <div data-testid="daily-cost-chart" />,
DailyTokensChart: () => <div data-testid="daily-tokens-chart" />,
WeeklyCostChart: () => <div data-testid="weekly-cost-chart" />,
WeeklyTokensChart: () => <div data-testid="weekly-tokens-chart" />,
ActivityHeatmap: ({ tz }: { tz: string }) => (
<div data-testid="heatmap-tz">{tz}</div>
),
}));
vi.mock("./custom-pricing-dialog", () => ({
CustomPricingDialog: () => null,
}));
import { UsageSection } from "./usage-section";
const RUNTIME: AgentRuntime = {
id: "r-1",
workspace_id: "ws-1",
daemon_id: null,
name: "test-runtime",
runtime_mode: "cloud",
provider: "claude",
launch_header: "",
status: "online",
device_info: "",
metadata: {},
owner_id: null,
visibility: "private",
last_seen_at: null,
created_at: "2026-05-01T00:00:00Z",
updated_at: "2026-05-01T00:00:00Z",
};
function Wrapper({ children }: { children: ReactNode }) {
return (
<I18nProvider locale="en" resources={TEST_RESOURCES}>
{children}
</I18nProvider>
);
}
describe("UsageSection — Viewing timezone wiring", () => {
beforeEach(() => {
runtimeUsageOptions.mockClear();
runtimeUsageByAgentOptions.mockClear();
});
it("fetches the trend in the viewer's tz", () => {
render(<UsageSection runtime={RUNTIME} />, { wrapper: Wrapper });
expect(runtimeUsageOptions).toHaveBeenCalled();
const [, days, tz] = runtimeUsageOptions.mock.calls[0]!;
expect(days).toBe(180);
expect(tz).toBe(VIEWER_TZ);
});
it("renders the heatmap in the viewer's tz", () => {
render(<UsageSection runtime={RUNTIME} />, { wrapper: Wrapper });
// The heatmap is an opt-in toggle inside the "When" card.
fireEvent.click(screen.getByRole("button", { name: "Heatmap" }));
expect(screen.getByTestId("heatmap-tz").textContent).toBe(VIEWER_TZ);
});
});

View File

@@ -13,7 +13,6 @@ import {
runtimeUsageByAgentOptions,
} from "@multica/core/runtimes/queries";
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
import { useViewingTimezone } from "../../common/use-viewing-timezone";
import {
formatTokens,
estimateCost,
@@ -130,12 +129,12 @@ function fmtMoney(n: number): string {
export function UsageSection({ runtime }: { runtime: AgentRuntime }) {
const { t } = useT("runtimes");
const runtimeId = runtime.id;
// Reports render in the viewer's timezone — the backend slices the UTC
// hourly rollup on the same `tz` we pass here, so every frontend window
// calculation shares one axis with the server.
const tz = useViewingTimezone();
// Tz comes from the runtime itself — the backend already buckets daily
// rows on `start-of-day in runtime tz`, so we use the same axis for every
// frontend window calculation. No "user timezone" option here on purpose.
const tz = runtime.timezone;
const { data: usage = [], isLoading: loading } = useQuery(
runtimeUsageOptions(runtimeId, 180, tz),
runtimeUsageOptions(runtimeId, 180),
);
const [dim, setDim] = useState<Exclude<WhenTab, "heatmap">>("daily");
const [days, setDays] = useState<TimeRange>(30);
@@ -284,7 +283,7 @@ export function UsageSection({ runtime }: { runtime: AgentRuntime }) {
/>
{/* Layer 3 — WHO/WHAT burned the spend. */}
<CostByBlock runtimeId={runtimeId} days={days} usage={filtered} tz={tz} />
<CostByBlock runtimeId={runtimeId} days={days} usage={filtered} />
{/* Layer 4 — Folded raw view. The Heatmap used to live here too; it
was promoted into the WHEN chart's toggle, leaving only the
@@ -600,12 +599,10 @@ function CostByBlock({
runtimeId,
days,
usage,
tz,
}: {
runtimeId: string;
days: number;
usage: RuntimeUsage[];
tz: string;
}) {
const { t } = useT("runtimes");
const [tab, setTab] = useState<"agent" | "model">("agent");
@@ -616,7 +613,7 @@ function CostByBlock({
// by-agent is server-side aggregation (fetched lazily on tab activation).
// by-model derives from the daily cache the parent already has — free.
const { data: byAgentRows = [] } = useQuery({
...runtimeUsageByAgentOptions(runtimeId, days, tz),
...runtimeUsageByAgentOptions(runtimeId, days),
enabled: tab === "agent",
});

View File

@@ -7,7 +7,6 @@ import {
aggregateByWeek,
aggregateCostByModel,
collectUnmappedModels,
computeCostInWindow,
estimateCost,
isModelPriced,
sliceWindow,
@@ -604,80 +603,3 @@ describe("aggregateByWeek", () => {
}
});
});
// computeCostInWindow drives the runtime-list cost cell and its ↑/↓ delta.
// The `tz` argument was inserted as the THIRD positional parameter (before
// `offsetDays`) in the timezone-architecture RFC — a positional-arg slip
// here is otherwise silent, so the window math, the end-exclusive boundary,
// the offset shift, and the tz-of-"today" all need explicit coverage.
describe("computeCostInWindow", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// claude-sonnet-4-6 is priced at $3 / 1M input tokens, so a row with
// 1M input tokens contributes exactly $3.
function priced(date: string, inputTokens: number): RuntimeUsage {
return {
runtime_id: "r",
date,
provider: "anthropic",
model: "claude-sonnet-4-6",
input_tokens: inputTokens,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
};
}
it("sums cost over the trailing daysBack window, end-exclusive of today", () => {
// 2026-05-19 23:00 UTC is already 2026-05-20 in Asia/Shanghai, so
// "today" is 2026-05-20 and the 7-day window is [2026-05-13, 2026-05-20).
vi.setSystemTime(new Date("2026-05-19T23:00:00Z"));
const rows = [
priced("2026-05-12", 1_000_000), // before window — excluded
priced("2026-05-13", 1_000_000), // window start — included
priced("2026-05-19", 1_000_000), // included
priced("2026-05-20", 1_000_000), // today — excluded (end-exclusive)
];
expect(computeCostInWindow(rows, 7, "Asia/Shanghai")).toBeCloseTo(6, 5);
});
it("offsetDays shifts the window back to the prior period", () => {
// today = 2026-05-20; offsetDays=7, daysBack=7 → window [05-06, 05-13).
vi.setSystemTime(new Date("2026-05-20T12:00:00Z"));
const rows = [
priced("2026-05-05", 1_000_000), // before prior window — excluded
priced("2026-05-06", 1_000_000), // prior window start — included
priced("2026-05-12", 1_000_000), // included
priced("2026-05-13", 1_000_000), // in the current window, not prior — excluded
];
expect(computeCostInWindow(rows, 7, "UTC", 7)).toBeCloseTo(6, 5);
});
it("reads 'today' in the supplied tz, not the host clock", () => {
// Host clock is 2026-05-19 in UTC but already 2026-05-20 in Shanghai.
// A row dated 2026-05-19 falls inside the 1-day window only when the
// tz pushes "today" forward to 2026-05-20.
vi.setSystemTime(new Date("2026-05-19T20:00:00Z"));
const rows = [priced("2026-05-19", 1_000_000)];
expect(computeCostInWindow(rows, 1, "UTC")).toBe(0); // today=05-19, window [05-18,05-19)
expect(computeCostInWindow(rows, 1, "Asia/Shanghai")).toBeCloseTo(3, 5);
});
it("returns 0 for an unpriced model rather than NaN", () => {
vi.setSystemTime(new Date("2026-05-20T12:00:00Z"));
const rows: RuntimeUsage[] = [
{ ...priced("2026-05-19", 1_000_000), model: "totally-made-up-model" },
];
expect(computeCostInWindow(rows, 7, "UTC")).toBe(0);
});
it("returns 0 for an empty row set", () => {
vi.setSystemTime(new Date("2026-05-20T12:00:00Z"));
expect(computeCostInWindow([], 7, "UTC")).toBe(0);
});
});

View File

@@ -730,29 +730,30 @@ export function aggregateCostByModel(rows: RuntimeUsage[]): CostByKey[] {
return [...map.values()].sort((a, b) => b.cost - a.cost);
}
// "Cost · 30D" KPI hint: percentage delta vs. the immediately prior window
// of equal length. Returns null when there's no comparable prior data
// (caller renders nothing rather than a misleading "+∞%").
// Sum of estimated cost over the trailing window
// [today offsetDays daysBack, today offsetDays).
// `offsetDays = 0, daysBack = 7` → last 7 days.
// `offsetDays = 7, daysBack = 7` → the 7 days *before* the last 7 (the
// "previous" window for the runtime-list ↑/↓ delta).
//
// "Today" is read in `tz` (the viewer's timezone) so the cutoff lands on
// the same calendar boundary the backend used when bucketing rows — the
// rows arrive bucketed in the viewer's tz, so slicing them with the JS
// engine's local tz would shift the window by a day at the edges.
//
// Walks the same daily-grain `RuntimeUsage` rows that `aggregateByDate` uses,
// so the runtime-list cost stays consistent with the runtime-detail KPIs
// (and crucially, hits the same TanStack Query cache key).
export function computeCostInWindow(
rows: readonly RuntimeUsage[],
daysBack: number,
tz: string,
offsetDays: number = 0,
): number {
const today = todayIso(tz);
const isoEnd = addDaysIso(today, -offsetDays);
const isoStart = addDaysIso(today, -offsetDays - daysBack);
const now = new Date();
const end = new Date(now);
end.setDate(now.getDate() - offsetDays);
const start = new Date(now);
start.setDate(now.getDate() - offsetDays - daysBack);
const isoEnd = end.toISOString().slice(0, 10);
const isoStart = start.toISOString().slice(0, 10);
let total = 0;
for (const r of rows) {
if (r.date >= isoStart && r.date < isoEnd) total += estimateCost(r);

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, act, cleanup, waitFor } from "@testing-library/react";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
@@ -11,10 +11,8 @@ const mockPersist = vi.hoisted(() => vi.fn());
const mockUpdateMe = vi.hoisted(() => vi.fn());
const mockReload = vi.hoisted(() => vi.fn());
const mockToastWarning = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
const mockSetUser = vi.hoisted(() => vi.fn());
const userRef = vi.hoisted(() => ({
current: null as { id: string; timezone?: string | null } | null,
current: null as { id: string } | null,
}));
vi.mock("@multica/ui/components/common/theme-provider", () => ({
@@ -41,7 +39,7 @@ vi.mock("@multica/core/api", () => ({
}));
vi.mock("sonner", () => ({
toast: { warning: mockToastWarning, error: mockToastError },
toast: { warning: mockToastWarning },
}));
vi.mock("@multica/core/auth", async () => {
@@ -49,18 +47,10 @@ vi.mock("@multica/core/auth", async () => {
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
type AuthState = {
user: typeof userRef.current;
setUser: typeof mockSetUser;
};
const state = (): AuthState => ({
user: userRef.current,
setUser: mockSetUser,
});
const useAuthStore = Object.assign(
(sel?: (s: AuthState) => unknown) =>
sel ? sel(state()) : state(),
{ getState: state },
(sel?: (s: { user: typeof userRef.current }) => unknown) =>
sel ? sel({ user: userRef.current }) : { user: userRef.current },
{ getState: () => ({ user: userRef.current }) },
);
return { ...actual, useAuthStore };
});
@@ -154,87 +144,3 @@ describe("PreferencesTab — Language switcher", () => {
expect(mockReload).toHaveBeenCalledTimes(1);
});
});
describe("PreferencesTab — Timezone section", () => {
beforeEach(() => {
vi.clearAllMocks();
userRef.current = null;
});
// Base UI Select portals its popup onto document.body; unmount each
// render fully between tests so a prior test's trigger/popup can't
// shadow the next one's.
afterEach(() => {
cleanup();
});
// Opens the Select popup and clicks the option whose accessible name
// matches. Re-queries the trigger each call so it operates on the
// current render, never a stale node.
async function pickTimezone(
user: ReturnType<typeof userEvent.setup>,
name: RegExp | string,
) {
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByRole("option", { name }));
}
it("renders the stored timezone in the trigger", () => {
userRef.current = { id: "user-1", timezone: "Asia/Shanghai" };
render(<PreferencesTab />, { wrapper: I18nWrapper });
expect(screen.getByRole("combobox").textContent).toContain("Asia/Shanghai");
});
// handleChange PATCHes then updates the store asynchronously, so the
// post-pick assertions must waitFor it to settle. The extended timeout
// covers querying the Select's full ~600-option IANA list on slow CI.
it("saving a new timezone PATCHes /api/me and updates the auth store", async () => {
userRef.current = { id: "user-1", timezone: "Asia/Shanghai" };
const updatedUser = { id: "user-1", timezone: "Asia/Tokyo" };
mockUpdateMe.mockResolvedValueOnce(updatedUser);
const user = userEvent.setup();
render(<PreferencesTab />, { wrapper: I18nWrapper });
await pickTimezone(user, "Asia/Tokyo");
await waitFor(() => {
expect(mockUpdateMe).toHaveBeenCalledWith({ timezone: "Asia/Tokyo" });
expect(mockSetUser).toHaveBeenCalledWith(updatedUser);
});
}, 20000);
it("surfaces a toast when the PATCH fails", async () => {
userRef.current = { id: "user-1", timezone: "Asia/Shanghai" };
mockUpdateMe.mockRejectedValueOnce(new Error("network down"));
const user = userEvent.setup();
render(<PreferencesTab />, { wrapper: I18nWrapper });
await pickTimezone(user, "Asia/Tokyo");
await waitFor(() => {
expect(mockUpdateMe).toHaveBeenCalledWith({ timezone: "Asia/Tokyo" });
expect(mockToastError).toHaveBeenCalledTimes(1);
});
expect(mockSetUser).not.toHaveBeenCalled();
}, 20000);
it("clearing the preference sends an empty-string timezone", async () => {
userRef.current = { id: "user-1", timezone: "Asia/Shanghai" };
const clearedUser = { id: "user-1", timezone: null };
mockUpdateMe.mockResolvedValueOnce(clearedUser);
const user = userEvent.setup();
render(<PreferencesTab />, { wrapper: I18nWrapper });
// The "(browser)" sentinel option resets the preference to NULL; the
// wire payload is an empty string the backend translates to NULL.
await pickTimezone(user, /browser/i);
await waitFor(() => {
expect(mockUpdateMe).toHaveBeenCalledWith({ timezone: "" });
// The PATCH response (timezone: null) is pushed into the auth store
// so the picker switches back to "(browser)" without a refetch.
expect(mockSetUser).toHaveBeenCalledWith(clearedUser);
});
}, 20000);
});

View File

@@ -1,14 +1,6 @@
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@multica/ui/components/ui/select";
import { useTheme } from "@multica/ui/components/common/theme-provider";
import { cn } from "@multica/ui/lib/utils";
import {
@@ -19,7 +11,6 @@ import {
import { useLocaleAdapter } from "@multica/core/i18n/react";
import { useAuthStore } from "@multica/core/auth";
import { api } from "@multica/core/api";
import { browserTimezone, timezoneOptions } from "../../common/timezone-select";
import { useT } from "../../i18n";
const LIGHT_COLORS = {
@@ -236,82 +227,6 @@ export function PreferencesTab() {
})}
</div>
</section>
<TimezoneSection />
</div>
);
}
// Base UI rejects "" as a SelectItem value, so route the "no preference"
// state through this sentinel and translate at the wire boundary.
const BROWSER_TZ_VALUE = "__browser__";
function TimezoneSection() {
const { t } = useT("settings");
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const stored = user?.timezone ?? null;
const browser = browserTimezone();
const value = stored ?? BROWSER_TZ_VALUE;
// Full IANA list (from timezoneOptions in common/timezone-select) so a
// user needing a non-curated zone isn't stuck with ~18 common ones.
// Memoized — timezoneOptions enumerates ~600 IANA zones per call.
const options = useMemo(
() => timezoneOptions(stored ?? browser),
[stored, browser],
);
const handleChange = async (next: string) => {
if (next === value) return;
const payload = next === BROWSER_TZ_VALUE ? "" : next;
try {
const updated = await api.updateMe({ timezone: payload });
setUser(updated);
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.preferences.timezone.sync_failed),
);
}
};
const formatTZLabel = (tz: string) => {
if (tz === BROWSER_TZ_VALUE) {
return `${browser}${t(($) => $.preferences.timezone.browser_suffix)}`;
}
return tz;
};
return (
<section className="space-y-2">
<h2 className="text-sm font-semibold">
{t(($) => $.preferences.timezone.title)}
</h2>
<Select
value={value}
onValueChange={(next) => {
if (next) handleChange(next);
}}
>
<SelectTrigger size="sm" className="w-72 rounded-md font-mono text-xs">
<SelectValue>{formatTZLabel(value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start" className="max-h-72">
<SelectItem value={BROWSER_TZ_VALUE} className="font-mono text-xs">
{formatTZLabel(BROWSER_TZ_VALUE)}
</SelectItem>
{options.map((tz) => (
<SelectItem key={tz} value={tz} className="font-mono text-xs">
{formatTZLabel(tz)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] leading-snug text-muted-foreground">
{t(($) => $.preferences.timezone.hint)}
</p>
</section>
);
}

View File

@@ -18,7 +18,6 @@ import { Users, Plus, Trash2, ArrowLeft, ArrowUpRight, Crown, Camera, Loader2, P
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
Popover,
PopoverContent,
@@ -117,7 +116,6 @@ export function SquadDetailPage() {
const [showAddMember, setShowAddMember] = useState(false);
const [showCreateAgent, setShowCreateAgent] = useState(false);
const [confirmArchive, setConfirmArchive] = useState(false);
const updateSquadMut = useMutation({
mutationFn: (data: { name?: string; description?: string; instructions?: string; avatar_url?: string; leader_id?: string }) => api.updateSquad(squadId, data),
@@ -203,7 +201,7 @@ export function SquadDetailPage() {
};
if (!squad) {
return <SquadDetailSkeleton />;
return <div className="p-6 text-muted-foreground text-sm">Loading...</div>;
}
const availableAgents = agents.filter((a: Agent) => !a.archived_at && !members.some((m) => m.member_type === "agent" && m.member_id === a.id));
@@ -227,7 +225,7 @@ export function SquadDetailPage() {
<SquadHeaderAvatar squad={squad} initials={initials} />
<h1 className="text-sm font-medium">{squad.name}</h1>
</div>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => setConfirmArchive(true)}>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => { if (confirm("Archive this squad? Issues will be transferred to the leader.")) deleteMut.mutate(); }}>
<Trash2 className="size-3.5 mr-1" />
{t(($) => $.inspector.archive_button)}
</Button>
@@ -290,70 +288,6 @@ export function SquadDetailPage() {
onCreate={handleCreateAgent}
/>
)}
{confirmArchive && (
<AlertDialog
open
onOpenChange={(v) => { if (!v && !deleteMut.isPending) setConfirmArchive(false); }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.archive_dialog.title)}</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.archive_dialog.description, { name: squad.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMut.isPending}>
{t(($) => $.archive_dialog.cancel)}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleteMut.isPending
? t(($) => $.archive_dialog.archiving)
: t(($) => $.archive_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);
}
// Initial-load skeleton — mirrors the two-column layout of the loaded page
// (left inspector + right tabs panel) so the swap to real content doesn't
// shift layout. Column widths match the md:/lg: breakpoints used below.
function SquadDetailSkeleton() {
return (
<div className="flex flex-1 min-h-0 flex-col">
<PageHeader className="px-5">
<Skeleton className="h-5 w-48" />
</PageHeader>
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="flex flex-col gap-4 rounded-lg border p-5">
<Skeleton className="h-16 w-16 rounded-lg" />
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3 w-full" />
<div className="space-y-2">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
<div className="flex flex-col gap-4 rounded-lg border p-6">
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
</div>
</div>
);
}

View File

@@ -11,7 +11,6 @@ import { PageHeader } from "../../layout/page-header";
import { Users, Plus, Search, Bot, User } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
import { useModalStore } from "@multica/core/modals";
import type { Agent, Squad } from "@multica/core/types";
@@ -84,7 +83,7 @@ export function SquadsPage() {
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
{isLoading ? (
<SquadsListSkeleton />
<div className="p-6 text-muted-foreground text-sm">Loading...</div>
) : squads.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<Users className="size-10 text-muted-foreground/50" />
@@ -185,30 +184,6 @@ function ScopeButton({ active, label, count, onClick }: { active: boolean; label
);
}
function SquadsListSkeleton() {
return (
<>
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
<Skeleton className="h-8 w-full max-w-sm rounded-md" />
<Skeleton className="h-7 w-32 rounded-md" />
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="grid gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 sm:gap-4 rounded-lg border p-3 sm:p-4">
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3 rounded" />
<Skeleton className="h-3 w-2/3 rounded" />
</div>
</div>
))}
</div>
</div>
</>
);
}
function SquadAvatar({ squad }: { squad: Squad }) {
const initials = squad.name
.split(" ")

View File

@@ -63,37 +63,19 @@ detect_os() {
# ---------------------------------------------------------------------------
# CLI Installation
# ---------------------------------------------------------------------------
_dump_brew_log() {
local log="$1"
if [ -s "$log" ]; then
warn "Homebrew output (last 80 lines):"
tail -n 80 "$log" | sed 's/^/ /' >&2
fi
}
install_cli_brew() {
info "Installing Multica CLI via Homebrew..."
local brew_log
brew_log=$(mktemp)
if ! brew tap multica-ai/tap >"$brew_log" 2>&1; then
warn "Failed to add Homebrew tap. Falling back to GitHub Releases binary install."
_dump_brew_log "$brew_log"
rm -f "$brew_log"
return 1
if ! brew tap multica-ai/tap 2>/dev/null; then
fail "Failed to add Homebrew tap. Check your network connection."
fi
# brew install exits non-zero if already installed on older Homebrew versions
if ! brew install "$BREW_PACKAGE" >"$brew_log" 2>&1; then
if ! brew install "$BREW_PACKAGE" 2>/dev/null; then
if brew list "$BREW_PACKAGE" >/dev/null 2>&1; then
rm -f "$brew_log"
ok "Multica CLI already installed via Homebrew"
else
warn "Failed to install multica via Homebrew. Falling back to GitHub Releases binary install."
_dump_brew_log "$brew_log"
rm -f "$brew_log"
return 1
fail "Failed to install multica via Homebrew."
fi
else
rm -f "$brew_log"
ok "Multica CLI installed via Homebrew"
fi
}
@@ -121,9 +103,8 @@ install_cli_binary() {
tar -xzf "$tmp_dir/multica.tar.gz" -C "$tmp_dir" multica
# Try /usr/local/bin first, fall back to ~/.local/bin. Tests and scripted
# installs can override the first choice with MULTICA_BIN_DIR.
local bin_dir="${MULTICA_BIN_DIR:-/usr/local/bin}"
# Try /usr/local/bin first, fall back to ~/.local/bin
local bin_dir="/usr/local/bin"
if [ -w "$bin_dir" ]; then
mv "$tmp_dir/multica" "$bin_dir/multica"
elif command_exists sudo; then
@@ -251,7 +232,7 @@ install_cli() {
fi
if command_exists brew; then
install_cli_brew || install_cli_binary
install_cli_brew
else
install_cli_binary
fi
@@ -463,15 +444,6 @@ main() {
echo " --with-server Install CLI + provision a self-host server (Docker)"
echo " --stop Stop a self-hosted installation"
echo ""
echo "Environment variables:"
echo " MULTICA_INSTALL_DIR Self-host server install directory"
echo " (default: \$HOME/.multica/server)"
echo " MULTICA_BIN_DIR Target directory for the CLI binary when"
echo " installing from GitHub Releases"
echo " (default: /usr/local/bin, then \$HOME/.local/bin)"
echo " MULTICA_SELFHOST_REF Git ref to check out for self-host assets"
echo " (default: latest release tag, falling back to main)"
echo ""
echo "After installation, run 'multica setup' to configure your environment."
exit 0
;;

Some files were not shown because too many files have changed in this diff Show More