mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 14:44:30 +02:00
Compare commits
5 Commits
fix/cloud-
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51074acbc6 | ||
|
|
624a639e03 | ||
|
|
4add27dbba | ||
|
|
da0b23357c | ||
|
|
32e873894c |
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:那一行任务的 agent;CLI / 不带 task_id:issue 当前的分配人 |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
|
||||
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
|
||||
|
||||
## 失败的任务对 issue 状态有什么影响
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.2),hourly 表的 `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/90d),raw 扫描足够快,没有必要从 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 100–104:
|
||||
|
||||
| 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 rollup:IANA 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-user(Preferences),不做 per-view picker |
|
||||
| hour-of-day heatmap tz | viewer tz(不再用机器物理 tz) |
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -36,8 +36,6 @@ export type {
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
RuntimeModel,
|
||||
RuntimeModelThinking,
|
||||
RuntimeModelThinkingLevel,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeModelListStatus,
|
||||
RuntimeModelsResult,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "清除覆盖,让本地 CLI(claude/codex)的配置决定推理级别"
|
||||
"model_clear_title": "清除并回退到运行时的提供方默认"
|
||||
},
|
||||
"model_dropdown": {
|
||||
"label": "模型",
|
||||
|
||||
@@ -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": "触发方式",
|
||||
|
||||
@@ -117,7 +117,6 @@
|
||||
"title_placeholder": "issue 标题",
|
||||
"description_placeholder": "添加描述...",
|
||||
"more_options_aria": "更多选项",
|
||||
"title_required": "请输入标题后再创建",
|
||||
"submit": "创建 issue",
|
||||
"submitting": "创建中...",
|
||||
"toast_created": "已创建 issue",
|
||||
|
||||
@@ -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 失败"
|
||||
|
||||
@@ -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": "未知",
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
"english": "English",
|
||||
"chinese": "中文",
|
||||
"sync_failed": "语言已在本设备保存,但同步到账号失败。其他设备可能仍显示旧语言。"
|
||||
},
|
||||
"timezone": {
|
||||
"title": "查看时区",
|
||||
"browser_suffix": "(浏览器)",
|
||||
"hint": "用于仪表盘、图表和「今天」标签。这是个人偏好,在你的所有工作区中通用。",
|
||||
"sync_failed": "保存时区偏好失败。"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -9,13 +9,6 @@
|
||||
"details_section": "详情",
|
||||
"archive_button": "归档"
|
||||
},
|
||||
"archive_dialog": {
|
||||
"title": "归档这个小队?",
|
||||
"description": "“{{name}}” 将被归档,该小队当前承接的 issue 会转交给小队负责人。此操作无法撤销,如需恢复路由请新建小队。",
|
||||
"cancel": "取消",
|
||||
"confirm": "归档",
|
||||
"archiving": "归档中…"
|
||||
},
|
||||
"name_editor": {
|
||||
"cancel": "取消"
|
||||
},
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(" ")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user