diff --git a/AGENTS.md b/AGENTS.md index 9d5a1ab16ea..6c23365b524 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,7 @@ Workspace-scoped queries filter by `workspace_id`; membership gates access and ` ## Change and Delivery Rules - Keep changes scoped; reuse existing patterns. Code comments are English. +- Use names, types, and tests to express behavior. Keep comments for non-obvious rationale and constraints; remove superseded plans and incident narratives when updating code. Preserve tool directives and mixed-version contracts. - Do not add internal compatibility shims, dual writes, fallback paths, or legacy adapters unless requested. This does not relax API response compatibility above. - New global pre-workspace routes use a single word or `/{noun}/{verb}`, not hyphenated root names. Update `server/internal/handler/reserved_slugs.json`, run `pnpm generate:reserved-slugs`, and commit `packages/core/paths/reserved-slugs.ts` when changing reserved slugs. - Use atomic conventional commits and the repository PR template. For releases, follow [.github/RELEASING.md](.github/RELEASING.md); default to a patch bump unless specified otherwise. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 83731c75e48..1c905886799 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@multica/desktop", "productName": "Multica", - "version": "0.6.2", + "version": "0.6.3", "private": true, "description": "Multica Desktop — native desktop client for the Multica platform.", "homepage": "https://multica.ai", diff --git a/apps/desktop/src/main/keyboard-shortcuts.ts b/apps/desktop/src/main/keyboard-shortcuts.ts index 3145b3bf762..26b525805ca 100644 --- a/apps/desktop/src/main/keyboard-shortcuts.ts +++ b/apps/desktop/src/main/keyboard-shortcuts.ts @@ -26,28 +26,10 @@ const ZOOM_MIN = -3; const ZOOM_MAX = 4.5; /** - * Inspect a `before-input-event` key and apply (or block) the matching - * window-level shortcut. Returns `true` when the caller should call - * `event.preventDefault()` — that both swallows the renderer keydown and - * prevents the application menu accelerator from firing, so we don't - * double-trigger zoom on macOS where the default menu also binds these - * keys. - * - * Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the - * default `Cmd+-` accelerator does not fire reliably across keyboard - * layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it). - * Handling the shortcuts here gives identical behavior on every platform - * and every layout. - */ -/** - * Result of handleAppShortcut: - * - `false`: not handled, let Electron continue - * - `true`: handled (preventDefault), no further action - * - `"close-tab"`: Cmd/Ctrl+W intercepted — caller should send IPC to renderer - * - `"open-settings"`: Cmd/Ctrl+, intercepted — caller should route the - * request to the tabbed main window - * - `{ action: "select-tab", key }`: Cmd/Ctrl+1..9 intercepted — caller - * should route the requested browser-style tab position to the main window + * Window shortcut outcomes tell the caller whether to continue, preventDefault, + * or dispatch a renderer action. Prevent handled input from also firing the menu + * accelerator. Handle zoom here for consistent keyboard-layout behavior. + * close-tab, open-settings, and select-tab actions route to the main window. */ export type ShortcutResult = | boolean diff --git a/apps/desktop/src/main/navigation-guard.ts b/apps/desktop/src/main/navigation-guard.ts index 59651b2fdb8..498ba050aa5 100644 --- a/apps/desktop/src/main/navigation-guard.ts +++ b/apps/desktop/src/main/navigation-guard.ts @@ -1,24 +1,8 @@ /** - * Top-level navigation hardening for renderer windows. - * - * Scope note (MUL-4899) — this is origin hardening ONLY, and deliberately NOT - * the mechanism that handles in-app links. - * - * Client-side routing never fires `will-navigate`: React Router pushes history - * entries without a document load. So an in-app path — a good one like - * `/acme/issues/1` or a bad one like `/Users/me/shot.png` pasted by an agent — - * never reaches this guard at all. Bad in-app paths are a routing concern, - * answered by the renderer's 404 page. Trying to police application paths here - * would be both ineffective (the hook does not see them) and wrong (it would - * break legitimate reloads). - * - * What it does catch is a real document-level navigation away from the trusted - * renderer: `window.location = 'https://evil.example'`, an anchor Chromium - * resolves to a remote URL in the same window, a dropped file URL. These windows - * run with `webSecurity: false` (the process sandbox is enabled), so a - * foreign document landing in one would inherit privileged context. New - * windows and external links already route through `setWindowOpenHandler` → - * `openExternalSafely`; this closes the same-window path. + * Guard document-level renderer navigation to trusted origins or the packaged + * entry file. Same-window foreign documents must not inherit renderer privileges. + * Client-side route changes do not fire will-navigate and remain the router's + * responsibility. New windows and external links use openExternalSafely. */ /** The subset of BrowserWindow this module needs — keeps it unit-testable. */ diff --git a/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx b/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx index b8e507ce5e4..5a7ec842998 100644 --- a/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx +++ b/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx @@ -40,10 +40,6 @@ vi.mock("@multica/core/auth", () => ({ selector(state.auth), })); -vi.mock("@multica/core/onboarding", () => ({ - useWelcomeStore: { getState: () => ({ reset: vi.fn() }) }, -})); - vi.mock("@multica/core/workspace/queries", () => ({ workspaceKeys: { list: () => ["workspace-list"] }, })); diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 6c0a5760839..c8546ffaa38 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -3,7 +3,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { CoreProvider } from "@multica/core/platform"; import { pickLocale, type SupportedLocale } from "@multica/core/i18n"; import { useAuthStore } from "@multica/core/auth"; -import { useWelcomeStore } from "@multica/core/onboarding"; import { workspaceKeys } from "@multica/core/workspace/queries"; import { useWorkspaceList } from "@multica/core/workspace"; import { api } from "@multica/core/api"; @@ -364,7 +363,6 @@ const sessionTeardown: SessionTeardown = { window.desktopAPI.reportAuthSession?.(userId), resetTabs: () => useTabStore.getState().reset(), closeOverlay: () => useWindowOverlayStore.getState().close(), - resetWelcome: () => useWelcomeStore.getState().reset(), clearDaemonToken: () => window.daemonAPI.clearToken(), stopDaemon: () => window.daemonAPI.stop(), }; diff --git a/apps/desktop/src/renderer/src/components/tab-bar.tsx b/apps/desktop/src/renderer/src/components/tab-bar.tsx index 8cd04478d2c..7ab3964cab8 100644 --- a/apps/desktop/src/renderer/src/components/tab-bar.tsx +++ b/apps/desktop/src/renderer/src/components/tab-bar.tsx @@ -47,33 +47,11 @@ import { parseIssueWindowPath } from "../../../shared/issue-window"; const TAB_SCROLL_FADE_SIZE = 24; const TAB_ENTRY_EASE = [0.22, 1, 0.36, 1] as const; -// Chrome-style merged tab: the active tab shares the content surface's fill and -// flares into it through concave bottom corners. Each flare is a small square -// whose radial gradient carves a quarter-circle notch (transparent, so the -// strip behind the flare shows through), strokes a 1px arc that continues the -// tab's side border into the content card's top ring, and fills the rest with -// the surface colour. The 0.4px spread on either side of the --surface-border -// pair anti-aliases that arc. -// -// That background-color is load-bearing, not decoration: the flare's bottom row -// overlaps the content card's top ring, and in dark mode --surface-border is -// translucent (oklch(1 0 0 / 10%)), so without an opaque layer beneath it the -// arc would composite over the ring instead of replacing it and the two keylines -// would stack into a markedly lighter line right where the straight ring meets -// the curve. It therefore has to be the strip's real backdrop — the sidebar -// wrapper's fill, which is conditional and not this file's to re-derive. -// SIDEBAR_WRAPPER_FILL_CLASS reads it off the wrapper itself. -// -// The backing may only reach as far as it is needed, though. A flare hangs 9px -// past the tab's edge, over the neighbouring tab, so an opaque square there -// prints over whatever that neighbour draws: its hover pill lost the whole -// corner it shares with the flare and read as a dark bite (MUL-6160). Masking -// the notch away removes the backing exactly where the gradient is transparent -// anyway, so the notch is a hole rather than a painted-on copy of the strip and -// shows whatever is actually behind it — bare strip usually, the neighbour's -// pill where it reaches in. The mask is opaque again by the radius where the -// arc's anti-aliasing starts, so every pixel the arc and the canvas fill cover -// keeps its backing. +// Concave tab flares need an opaque backing beneath translucent border arcs +// to avoid compositing a second keyline over the content ring. Read that backdrop +// from SIDEBAR_WRAPPER_FILL_CLASS rather than duplicating the wrapper's fill rule. +// Mask the transparent notch so the backing cannot paint over a neighboring hover +// pill, while retaining backing beneath the arc's antialiasing. const TAB_FLARE_RADIUS = 10; const tabFlareGradient = (side: "left" | "right") => { const r = TAB_FLARE_RADIUS; diff --git a/apps/desktop/src/renderer/src/components/workspace-route-layout.test.tsx b/apps/desktop/src/renderer/src/components/workspace-route-layout.test.tsx index d828c9ff877..3e83ec4b823 100644 --- a/apps/desktop/src/renderer/src/components/workspace-route-layout.test.tsx +++ b/apps/desktop/src/renderer/src/components/workspace-route-layout.test.tsx @@ -89,10 +89,6 @@ vi.mock("@multica/views/workspace/use-workspace-seen", () => ({ useWorkspaceSeen: () => state.workspaceSeen, })); -vi.mock("@multica/views/workspace/welcome-after-onboarding", () => ({ - WelcomeAfterOnboarding: () => null, -})); - vi.mock("@multica/views/layout", () => ({ WorkspacePresencePrefetch: () => null, })); diff --git a/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx b/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx index 088cf364448..86df481d761 100644 --- a/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx +++ b/apps/desktop/src/renderer/src/components/workspace-route-layout.tsx @@ -10,7 +10,6 @@ import { getCurrentSlug, setCurrentWorkspace } from "@multica/core/platform"; import { isWorkspaceDeletePending } from "@multica/core/workspace/pending-delete"; import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen"; -import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding"; import { WorkspacePresencePrefetch } from "@multica/views/layout"; import { SourceBackfillModal } from "@multica/views/onboarding"; import { useTabStore } from "@/stores/tab-store"; @@ -57,12 +56,11 @@ export function WorkspaceRouteLayout() { const isAuthLoading = useAuthStore((s) => s.isLoading); // While a WindowOverlay is open (onboarding, accept-invite, new-workspace), // the underlying tab is still mounted in the React tree — so this layout - // and its WelcomeAfterOnboarding Modal would render UNDER the overlay. + // and its SourceBackfillModal would render UNDER the overlay. // Because the modal uses a Portal that targets document.body, it ends up // rendered LATER in the DOM and visually outranks the overlay's z-50. - // Suppress the modal whenever any overlay is active; the moment the - // overlay closes the welcome hook re-evaluates and pops if its store - // signal is still set. + // Suppress the modal whenever any overlay is active; it re-evaluates the + // moment the overlay closes. const overlayActive = useWindowOverlayStore((s) => s.overlay !== null); // Workspace routes require auth. App.tsx renders @@ -179,20 +177,12 @@ export function WorkspaceRouteLayout() { - {/* Reads the welcome-store transient signal parked by - * OnboardingFlow.handleRuntimeNext. Suppressed while a WindowOverlay - * (onboarding / accept-invite / new-workspace) is open so the modal - * doesn't portal-jump in front of an active pre-workspace flow. - * Once the overlay closes the hook re-evaluates and pops the - * Modal — unless the store signal has already been consumed, in - * which case the hook renders null. */} - {!overlayActive && } {/* Source-attribution backfill: same Dialog the web shell mounts * inside DashboardLayout. Desktop's WorkspaceRouteLayout doesn't * wrap DashboardLayout, so the modal has to be wired in directly - * here. Same overlay-suppression rule as WelcomeAfterOnboarding — - * a portal-rendered Dialog at z-50 would otherwise sit above an - * active pre-workspace overlay. */} + * here. Suppressed while a WindowOverlay is open — a portal-rendered + * Dialog at z-50 would otherwise sit above an active pre-workspace + * overlay. */} {!overlayActive && } ); diff --git a/apps/desktop/src/renderer/src/platform/session-teardown.test.ts b/apps/desktop/src/renderer/src/platform/session-teardown.test.ts index 614a0ee2b83..db8e1fbd3e3 100644 --- a/apps/desktop/src/renderer/src/platform/session-teardown.test.ts +++ b/apps/desktop/src/renderer/src/platform/session-teardown.test.ts @@ -14,7 +14,6 @@ function makeTeardown( reportAuthSession: vi.fn(), resetTabs: vi.fn(), closeOverlay: vi.fn(), - resetWelcome: vi.fn(), clearDaemonToken: vi.fn(async () => {}), stopDaemon: vi.fn(async () => {}), ...overrides, @@ -33,7 +32,6 @@ describe("tearDownOnLogout", () => { expect(t.reportAuthSession).toHaveBeenCalledWith(null); expect(t.resetTabs).toHaveBeenCalledOnce(); expect(t.closeOverlay).toHaveBeenCalledOnce(); - expect(t.resetWelcome).toHaveBeenCalledOnce(); expect(t.clearDaemonToken).toHaveBeenCalledOnce(); expect(t.stopDaemon).toHaveBeenCalledOnce(); }); @@ -82,6 +80,5 @@ describe("tearDownOnSessionExpiry", () => { expect(t.reportAuthSession).toHaveBeenCalledWith(null); expect(t.resetTabs).toHaveBeenCalledOnce(); expect(t.closeOverlay).toHaveBeenCalledOnce(); - expect(t.resetWelcome).toHaveBeenCalledOnce(); }); }); diff --git a/apps/desktop/src/renderer/src/platform/session-teardown.ts b/apps/desktop/src/renderer/src/platform/session-teardown.ts index dc98b01b5c9..ce6ecd89c84 100644 --- a/apps/desktop/src/renderer/src/platform/session-teardown.ts +++ b/apps/desktop/src/renderer/src/platform/session-teardown.ts @@ -25,8 +25,6 @@ export interface SessionTeardown { resetTabs: () => void; /** Any pre-workspace overlay left open (invite, onboarding, …). */ closeOverlay: () => void; - /** The one-shot post-onboarding welcome signal. */ - resetWelcome: () => void; /** Credential the local daemon authenticates with. */ clearDaemonToken: () => Promise; /** The local daemon process itself. Resolves to an IPC result we ignore — @@ -47,7 +45,6 @@ export async function tearDownOnLogout(t: SessionTeardown): Promise { t.reportAuthSession(null); t.resetTabs(); t.closeOverlay(); - t.resetWelcome(); try { await t.clearDaemonToken(); } catch { @@ -75,5 +72,4 @@ export function tearDownOnSessionExpiry(t: SessionTeardown): void { t.reportAuthSession(null); t.resetTabs(); t.closeOverlay(); - t.resetWelcome(); } diff --git a/apps/desktop/src/renderer/src/platform/tab-coordinator.ts b/apps/desktop/src/renderer/src/platform/tab-coordinator.ts index 7df5b666f94..afe07e16b86 100644 --- a/apps/desktop/src/renderer/src/platform/tab-coordinator.ts +++ b/apps/desktop/src/renderer/src/platform/tab-coordinator.ts @@ -14,24 +14,10 @@ import { } from "@/stores/tab-store"; /** - * Tab Coordinator (MUL-4741 Phase 2) — the ONLY writer of the single app - * router. - * - * Architecture: the tab store is the source of truth; the router is a - * projection of the active session's URL. The Coordinator subscribes to the - * store and *reconciles* the router to `activeSession.url`, stamping every - * navigation with a token. All entry points (tab bar clicks, adapter - * push/replace, shell back/forward, error recovery) mutate the store — none - * of them touch the router. This makes invariant 1 hold by construction: - * - * - Router location change WITH a pending token → expected, consume it. - * - Router location change WITHOUT a pending token → protocol error → - * bounded recovery (re-reconcile toward the session URL, capped). - * - * The router's own history is never used: the Coordinator always navigates - * with `replace`, and per-tab back/forward is a session operation over the - * session's virtual history stack. A POP therefore can only come from - * unforeseen code and is handled by the same protocol-error path. + * Project the active tab session onto the single app router. Navigation entry + * points mutate the store; only this coordinator writes the router, using replace + * and a pending token. Untokened location changes trigger bounded reconciliation. + * Per-tab virtual history owns back/forward; router POP is a protocol error. */ let router: DataRouter | null = null; diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts index 0df8289f526..f8c8be09b3c 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.ts @@ -5,17 +5,9 @@ import { createPersistStorage, defaultStorage } from "@multica/core/platform"; import { createSafeId } from "@multica/core/utils"; import { isReservedSlug } from "@multica/core/paths"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- -// -// MUL-4741 Phase 2: tabs are *sessions*, not routers. A TabSession is pure -// serializable state — URL, identity, virtual history, and a memento of -// restorable view state. The single app router is a projection of the active -// session, driven exclusively by the tab Coordinator (src/platform/ -// tab-coordinator.ts) which subscribes to this store and reconciles the -// router to `activeSession.url` with a navigation token. Nothing in this -// file touches react-router. +// Tab sessions hold serializable URLs, identity, virtual history, and view +// mementos. The coordinator projects the active session onto the single router; +// this store does not operate the router. /** * One captured scroll position. @@ -293,9 +285,7 @@ interface TabStore { reset: () => void; } -// --------------------------------------------------------------------------- // Session identity helpers -// --------------------------------------------------------------------------- // // A tab's icon is NOT part of this model. It is derived from `tab.url` at // render time via `routeIconForPath` (@multica/views/layout), which shares the @@ -312,9 +302,7 @@ function extractWorkspaceSlug(path: string): string | null { return first; } -// --------------------------------------------------------------------------- // URL helpers -// --------------------------------------------------------------------------- /** Split a tab URL into pathname and the search+hash suffix. */ export function splitTabUrl(url: string): { pathname: string; suffix: string } { @@ -405,9 +393,7 @@ export function sanitizeTabPath(path: string): string | null { return path; } -// --------------------------------------------------------------------------- // Session factory -// --------------------------------------------------------------------------- function createId(): string { return createSafeId(); @@ -618,9 +604,7 @@ function normalizeBrowsingHistoryTitles( return result; } -// --------------------------------------------------------------------------- // Group helpers -// --------------------------------------------------------------------------- /** * Build a group with its MRU order reconciled against the new (tabs, @@ -711,9 +695,7 @@ function buildCloseOtherTabsResult( }; } -// --------------------------------------------------------------------------- // Store -// --------------------------------------------------------------------------- export const useTabStore = create()( persist( @@ -1497,9 +1479,7 @@ function setHistoryIndex( }); } -// --------------------------------------------------------------------------- // Persisted shapes (for migration) -// --------------------------------------------------------------------------- interface V1Tab { id: string; @@ -1711,9 +1691,7 @@ export function migrateV1ToV2(v1: Partial): V2Persisted { return { activeWorkspaceSlug, byWorkspace }; } -// --------------------------------------------------------------------------- // Selectors (convenience hooks) -// --------------------------------------------------------------------------- /** * Pure non-hook helper — useful from event handlers / effects that already diff --git a/apps/docs/content/docs/project-resources.mdx b/apps/docs/content/docs/project-resources.mdx index 16b43d695f9..06540af3d86 100644 --- a/apps/docs/content/docs/project-resources.mdx +++ b/apps/docs/content/docs/project-resources.mdx @@ -81,6 +81,7 @@ What the agent sees, and what you get back: - **The agent starts from what you see, not from `HEAD`.** Your uncommitted edits and your untracked files are replayed into the worktree, so the agent is not reviewing code you no longer have. Your own working copy, index, and stash list are never touched. If that state cannot be reproduced faithfully — including when there is more untracked content than the replay covers (2000 files / 200 MiB) — the run fails instead of starting from a tree you would not recognise. The usual fix is to gitignore or clean up build output that is not already ignored. - **The result is a branch in your repo**, one per issue rather than one per run: `agent//` (a chat gets `agent//chat-`). The run's **Run details** panel shows the branch name (and lets you copy it); you can also find it with `git branch`. Review it with `git log` / `git diff`, and merge or cherry-pick it yourself. Multica never merges it for you. A run that failed partway still reports its branch, because it still committed whatever the agent had produced. - **A follow-up continues where the last run stopped.** The next comment on the same issue checks that branch out again, so the agent is standing on the work it just delivered instead of starting from `HEAD` with its own changes stranded on another branch. Only what you changed in your own directory since the last run is replayed on top — the branch already carries the rest. Merge the branch and the next run starts from your `HEAD` again. If two runs on one issue overlap, the second one forks from the first's work onto `agent//-`, because git allows one worktree per branch. +- **Committed updates to your local `HEAD` are kept separate from uncommitted edits.** A pull or local commit between turns is not replayed onto the issue branch; merge those commits into it explicitly when needed. If the previous run captured uncommitted work, comparing it on the new `HEAD` requires Git 2.40 or later. If those older edits conflict with the new committed baseline, the runtime logs a warning and uses the current `HEAD` only for the affected paths; other drafts keep their incremental comparison. Finishing and committing one draft therefore does not block future runs or re-propose your unchanged drafts against the agent's work. Any conflict between your current edits and the agent's work is still offered to the agent to resolve. - **If your edits collide with the agent's, the agent resolves them.** When you rewrite the same lines the agent rewrote, git cannot decide which version wins, so the run starts on a conflicted worktree and is told to finish the merge before anything else — `git status` in that worktree lists the unmerged paths. A run that leaves any file unmerged delivers nothing: it fails, keeps the worktree, and the next run offers the same edits again, so your change is never quietly dropped. - **Only branches Multica owns are continued.** Continuing a branch is decided by an ownership record, not by its name: the record names the conversation, and it names the commit the branch was left at. A branch you created yourself that happens to be called `agent//` is never checked out, appended to, or read as a previous run — and neither is one that no longer contains the recorded commit, so deleting Multica's branch and making your own under that name, or force-moving it onto other history, is safe. In those cases the run takes `agent//-` instead. Committing on top of a delivered branch is the normal case and keeps it continuing — your commit is simply there in the next run. Every branch also starts with a baseline commit of its own, marking the tree the first run began from — that commit is what identifies the branch later, and `git diff ..` is exactly the agent's work. A run that could not record its branch — it failed before finishing, the record could not be written, or it reset its worktree past the commit that carried your edits into the branch — reports failure and keeps its worktree, and later runs start a new branch rather than continuing one they cannot prove is theirs. - **Nothing is silently dropped.** Anything the agent leaves uncommitted is committed to that branch before the worktree is removed, including when the run fails partway. In the rare case that commit itself cannot be made — for example a repo with `commit.gpgSign` and no signing key available to the runtime — the worktree is deliberately kept instead of removed, the run is reported as failed, and `git worktree list` in your repo points at the directory holding the work. diff --git a/apps/mobile/app/(app)/[workspace]/issue/[id]/picker/status.tsx b/apps/mobile/app/(app)/[workspace]/issue/[id]/picker/status.tsx index 3a175662d6b..a2bb6cbd847 100644 --- a/apps/mobile/app/(app)/[workspace]/issue/[id]/picker/status.tsx +++ b/apps/mobile/app/(app)/[workspace]/issue/[id]/picker/status.tsx @@ -3,34 +3,105 @@ * (UISheetPresentationController) by the parent Stack. * * Self-contained: reads the issue from the TanStack Query detail cache, - * calls `useUpdateIssue` directly on selection, then `router.back()`s. No + * writes through `useUpdateIssue` on selection, then `router.back()`s. No * onChange callback to a parent. * * If the cache is cold (rare — the user reaches this screen by tapping * a chip on the issue-detail page that already populated it), the picker * still renders against the current value of `todo` and the optimistic * mutation patches the cache when the user picks. + * + * A project workflow can hand the issue off when it enters a status, so the + * change is previewed first (`planStatusChange`): a handoff is confirmed in a + * native alert, and the write carries the previewed binding either way. A + * failed preview, a cancelled confirmation or a stale binding writes nothing. */ +import { useRef } from "react"; +import { Alert } from "react-native"; import { useLocalSearchParams, router } from "expo-router"; import { useQuery } from "@tanstack/react-query"; +import type { IssueStatus, UpdateIssueRequest } from "@multica/core/types"; import { StatusPickerBody } from "@/components/issue/pickers/status-picker-body"; +import { ApiError } from "@/data/api"; import { issueDetailOptions } from "@/data/queries/issues"; import { useUpdateIssue } from "@/data/mutations/issues"; +import { useActorLookup } from "@/data/use-actor-name"; import { useWorkspaceStore } from "@/data/workspace-store"; +import { planStatusChange, type StatusChangePlan } from "@/data/workflow-handoff"; +import { statusOptions } from "@/lib/issue-status"; +import { useIssueStatuses } from "@/lib/use-issue-statuses"; +import { useT } from "@/lib/i18n"; export default function IssueStatusPickerRoute() { const { id } = useLocalSearchParams<{ id: string }>(); const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); const { data: issue } = useQuery(issueDetailOptions(wsId, id)); const updateIssue = useUpdateIssue(id); + const { t } = useT("issues"); + const catalog = useIssueStatuses(); + const { getName } = useActorLookup(); + // One preview or write at a time: a second tap must not start another. + const busy = useRef(false); + + const write = async (request: UpdateIssueRequest) => { + try { + await updateIssue.mutateAsync(request); + router.back(); + } catch (err) { + const stale = err instanceof ApiError && err.status === 409; + Alert.alert( + t("workflow_handoff.failed_title"), + stale + ? t("workflow_handoff.stale_message") + : err instanceof Error + ? err.message + : t("common:states.error"), + ); + } + }; + + const confirm = (plan: Extract, next: IssueStatus) => { + const { preview } = plan; + const handler = getName(preview.handler_type, preview.handler_id); + const stopsRuns = preview.previous_runs.length > 0; + const label = statusOptions(catalog).find((option) => option.key === next)?.label ?? next; + Alert.alert( + t("workflow_handoff.confirm_title", { status: label }), + t(stopsRuns ? "workflow_handoff.confirm_message_runs" : "workflow_handoff.confirm_message", { handler }), + [ + { text: t("workflow_handoff.cancel"), style: "cancel" }, + { text: t("workflow_handoff.move"), onPress: () => void write(plan.request(false)) }, + ...(stopsRuns + ? [{ text: t("workflow_handoff.move_and_stop"), onPress: () => void write(plan.request(true)) }] + : []), + ], + ); + }; + + const choose = async (next: IssueStatus) => { + if (busy.current) return; + if (next === issue?.status) { + router.back(); + return; + } + busy.current = true; + try { + const plan = await planStatusChange(id, next); + if (plan.kind === "apply") await write(plan.request); + else confirm(plan, next); + } catch (err) { + Alert.alert( + t("workflow_handoff.check_failed_title"), + t("workflow_handoff.check_failed_message", { message: err instanceof Error ? err.message : "" }), + [ + { text: t("workflow_handoff.cancel"), style: "cancel" }, + { text: t("workflow_handoff.retry"), onPress: () => void choose(next) }, + ], + ); + } finally { + busy.current = false; + } + }; - return ( - { - updateIssue.mutate({ status: next }); - router.back(); - }} - /> - ); + return void choose(next)} />; } diff --git a/apps/mobile/app/(app)/[workspace]/search.tsx b/apps/mobile/app/(app)/[workspace]/search.tsx index 4b976102dca..d276372550c 100644 --- a/apps/mobile/app/(app)/[workspace]/search.tsx +++ b/apps/mobile/app/(app)/[workspace]/search.tsx @@ -65,9 +65,7 @@ const SEARCH_HEADER_LABELS: Record = { "h-cancelled": "issues:status.cancelled", }; -// ===================================================== // HighlightText — mobile port of web's HighlightText -// ===================================================== // Web uses an HTML which doesn't exist in RN, so we segment the // string ourselves and wrap matched parts in a styled . Same regex // escape + case-insensitive substring match as @@ -125,9 +123,7 @@ function HighlightText({ ); } -// ===================================================== // Row item types — drives the single FlatList render -// ===================================================== // RowItem + buildSearchRows live in lib/search-rows.ts so the ordering rules // (including the cancelled partition) are testable without mounting the screen. @@ -304,9 +300,7 @@ function RecentRow({ item, slug }: RecentRowProps) { ); } -// ===================================================== // Screen -// ===================================================== interface SearchResultsState { issues: SearchIssueResult[]; diff --git a/apps/mobile/components/chat/chat-message-list.tsx b/apps/mobile/components/chat/chat-message-list.tsx index b78f459a968..05cbd457f1f 100644 --- a/apps/mobile/components/chat/chat-message-list.tsx +++ b/apps/mobile/components/chat/chat-message-list.tsx @@ -1,44 +1,11 @@ /** - * Chat message list — user / assistant bubbles, oldest at top, newest at - * bottom. Initial render lands at the bottom; new arrivals auto-scroll - * when the user is anchored near the bottom; reading history is never - * yanked down. + * Chronological FlashList chat with initial bottom positioning and MVCP. + * Follow new messages only while the user is near the bottom; preserve reading + * position through asynchronous Markdown and image resizing. * - * Behavioral parity (apps/mobile/CLAUDE.md): - * - Render ALL message roles. Unknown role values are downgraded to - * "assistant" by ChatMessageSchema's `.catch()`, so this list never - * needs to silently drop a row. - * - Render `failure_reason` messages with destructive styling — same - * boolean as web's destructive bubble + failureReasonLabel(). - * - * v1 simplifications: - * - No "Replied in Ns" badge under assistant bubbles (elapsed_ms is - * parsed but not displayed). Easy v2 add — show below the bubble. - * - Attachments bound to a message but NOT referenced inline in `content` - * render as standalone cards below the bubble via `CommentAttachmentList` - * (same component the comment thread uses; mirrors web reusing - * `AttachmentList` in chat). Inline `![](url)` / `[name](url)` still flow - * through the markdown renderer and are de-duped out of the card list. - * - * Interaction: long-press inside a bubble fires a native iOS - * `ActionSheetIOS` (Copy / Select Text / Cancel). While the sheet is on - * screen the targeted bubble's border highlights. The assistant branch - * has no border baseline because its bubble has no shell — adding a 2px - * baseline would shift layout per message. See `useChatMessageLongPress` - * in `./message-long-press.tsx`. - * - * List engine: FlashList v2 (Shopify). FlatList was the original choice - * (per the now-outdated "no FlashList" baseline in apps/mobile/CLAUDE.md - * — written before FlashList v2 stabilised). FlatList's `scrollToEnd` is - * janky on variable-height lists by RN's own docs admission, and our - * markdown bubbles render in multiple async passes (Shiki highlight, - * image natural-size, lightbox provider injection) — each pass used to - * fire onContentSizeChange and trigger another forced scroll, causing - * the "open chat → feels stuck" jank. FlashList v2 replaces the manual - * scroll dance with `maintainVisibleContentPosition` - * (default-on; locks visible item across content changes) + - * `startRenderingFromBottom` (initial paint at bottom, no setTimeout - * hacks). Cell recycling also keeps scroll-up smooth. + * Render all normalized message roles and failure styling. Attachments not used + * inline appear as standalone cards. Long-press actions highlight the target + * without adding a border that would shift the layout. */ import { useMemo, useState } from "react"; import { ActivityIndicator, Pressable, View } from "react-native"; diff --git a/apps/mobile/components/issue/comment-context-menu.tsx b/apps/mobile/components/issue/comment-context-menu.tsx index 8b5caf57a55..50a6675279c 100644 --- a/apps/mobile/components/issue/comment-context-menu.tsx +++ b/apps/mobile/components/issue/comment-context-menu.tsx @@ -1,21 +1,9 @@ /** - * Long-press handler for a comment bubble. Exposes `onLongPress` (drives a - * native iOS ActionSheetIOS) and `isPressed` (drives the caller's highlight - * ring while the sheet is on screen). + * Native long-press actions for a comment bubble, with a pressed highlight + * while the sheet is visible. Reply targets the inline composer. * - * iOS-native first per apps/mobile/CLAUDE.md §UI components → waterfall step - * 1: `ActionSheetIOS.showActionSheetWithOptions`. Zero custom layout, zero - * animation, zero overflow math, zero new deps. - * - * Item set (conditional, mirrors web's comment context menu): - * Reply (stub) · React… (opens nested sheet) · Copy · Select Text · - * Copy Link · Resolve/Unresolve Thread (root only) · Delete (own only) · - * Cancel - * - * The nested React… sheet (5 quick emojis + More reactions… + Cancel) is - * fired from INSIDE the outer sheet's completion callback rather than - * inline, because iOS will refuse to present a second ActionSheet while the - * first is still dismissing — the callback runs after dismissal completes. + * Present nested reaction sheets from the outer sheet's completion callback; + * iOS cannot present the next ActionSheet while the first is dismissing. */ import { useCallback, useState } from "react"; import { ActionSheetIOS, Alert } from "react-native"; diff --git a/apps/mobile/components/issue/composer-attachment-row.tsx b/apps/mobile/components/issue/composer-attachment-row.tsx index 00f588a8bc7..4308572c31d 100644 --- a/apps/mobile/components/issue/composer-attachment-row.tsx +++ b/apps/mobile/components/issue/composer-attachment-row.tsx @@ -121,9 +121,7 @@ export function ComposerAttachmentRow({ ); } -// --------------------------------------------------------------------------- // Mention chip — small capsule, no tap (only × removes). -// --------------------------------------------------------------------------- function MentionChipView({ mention, @@ -166,9 +164,7 @@ function MentionChipView({ ); } -// --------------------------------------------------------------------------- // Attachment chip — image / file capsule with status overlay. -// --------------------------------------------------------------------------- interface AttachmentChipProps { item: ComposerAttachmentItem; diff --git a/apps/mobile/components/issue/timeline-list.tsx b/apps/mobile/components/issue/timeline-list.tsx index 20b2e513f93..a9dd10cf3f3 100644 --- a/apps/mobile/components/issue/timeline-list.tsx +++ b/apps/mobile/components/issue/timeline-list.tsx @@ -1,79 +1,12 @@ /** - * The scrolling timeline. ASC chronological — oldest at top, newest near the - * bottom (above the composer). Pull-to-refresh refetches issue + timeline. + * Chronological issue timeline backed by FlashList. + * Normal opens show the header; deep links start at the bottom and highlight a + * target when its row is rendered. MVCP keeps the viewport stable as asynchronous + * Markdown, images, and WebSocket patches change row heights. * - * Backend returns the full timeline in one shot (server-side pagination - * was dropped in #2322 — p99 ~30 entries per issue, cursor walking only - * created bugs at reply-thread boundaries). The previous "Pull to load - * older" UX and top-edge `fetchOlder` trigger are gone. - * - * Inbox deep-link — FlashList v2 `startRenderingFromBottom` (mirrors - * chat-message-list.tsx): - * When `highlightCommentId` is set, we pass - * `maintainVisibleContentPosition.startRenderingFromBottom: true` to - * FlashList and remount the list via `key={`hl-${highlightNonce}`}` once - * timeline data has arrived. FlashList's `getInitialScrollIndex()` then - * lands on the last data item; after the initial paint MVCP keeps the - * visible content stable across async resizes (Shiki highlight, image - * natural-size, WS `comment:updated`). No JS-side scroll dance. - * - * The previous implementation hand-rolled a `landed` state gate that - * re-fired `scrollToEnd` on every `onContentSizeChange`. That deadlocked - * the user at the bottom: `onScrollBeginDrag` queued `setLanded(true)` - * but a concurrent `onContentSizeChange` (from MVCP's own ScrollAnchor - * reposition, or from a markdown pass finishing) read the stale React - * closure, saw `landed === false`, and slammed the user back to bottom - * before the commit landed. State-vs-event races don't survive iOS - * markdown rendering hogging the JS thread. - * - * The matching 's `RootHighlightOverlay` fires when the - * target row enters the render window — so for a deep-link pointing - * at an old comment, the user scrolls up and the flash plays as the - * row mounts. `HIGHLIGHT_HOLD_MS` (5s) is the window for that. - * - * Why not `scrollToIndex`: it requires accurate height estimates that - * variable-height markdown bubbles can't provide, even with - * `onScrollToIndexFailed` retry. Same lesson web learned (see - * `packages/views/issues/components/issue-detail.tsx:1822-1850` — they - * split the path into virtualized-for-browse / flat-for-deep-link - * precisely because "virtualization and 'land precisely on a target' - * have fundamentally opposed contracts"). Mobile sidesteps the split - * by not pretending to land precisely. - * - * `maintainVisibleContentPosition` is enabled by default on FlashList v2 - * and is implemented inside the C++ shadow tree — it compensates the - * scroll offset both when a row is INSERTED above the viewport AND when - * an upper row RESIZES (a WS `comment:updated` / `reaction:added` / - * `resolved` event on an older comment used to push the user's read - * position down by the delta). On FlatList the same prop was iOS-only - * + JS-side; the FlashList path is steadier and animates the offset - * compensation rather than snapping it. - * - * List engine: FlashList v2 (Shopify). Migrated from FlatList because: - * 1. Cell recycling — markdown bubbles (Shiki highlight, image natural- - * size, lightbox provider injection) are expensive to mount; FlashList - * keeps them in recycled cells when scrolling through history rather - * than re-running the multi-pass render each time a row re-enters the - * window. - * 2. Native MVCP — see paragraph above. Smoother behavior than FlatList's - * JS-side implementation when WS events resize an upper row. - * 3. Async-render stability — async markdown size changes inside the - * viewport no longer cause the visual "twitch" we saw with FlatList, - * because FlashList re-layouts inside the shadow tree without - * surfacing a JS-side onContentSizeChange storm. - * - * What FlashList v2 does NOT change about this file: - * - The deep-link "land at bottom + flash on row mount" pattern (Linear - * iOS). FlashList's `scrollToIndex` is still estimate-based, so we - * intentionally don't try to land precisely on a specific comment — - * we land at the bottom and let `RootHighlightOverlay` claim the - * target when the user scrolls past it. `startRenderingFromBottom` - * is NOT enabled: a normal issue-open should show the header (title, - * description, status) first, not jump straight to the latest reply. - * - Spacing between rows. FlashList ignores `gap-*` on - * `contentContainer` the same way it does in chat-message-list.tsx — - * we use `ItemSeparatorComponent` for the 12 px breathing room and - * `ListHeaderComponentStyle` to add the same gap below the header. + * Avoid repeated scrollToEnd calls during content resizing: they can override + * user scrolling. New replies use the explicit jump-to-new chip instead of + * automatically following appends. Item separators provide spacing between rows. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { diff --git a/apps/mobile/data/api.ts b/apps/mobile/data/api.ts index 9d89a10d190..8deca6d7616 100644 --- a/apps/mobile/data/api.ts +++ b/apps/mobile/data/api.ts @@ -59,6 +59,7 @@ import type { User, Workspace, WorkspaceSubscriptionSummary, + WorkflowHandoffPreview, } from "@multica/core/types"; import { AppConfigSchema, @@ -72,6 +73,7 @@ import { ListIssuesResponseSchema, ListIssueStatusesResponseSchema, TimelineEntriesSchema, + WorkflowHandoffPreviewSchema, WorkspaceSubscriptionSummarySchema, } from "@multica/core/api/schemas"; import type { @@ -688,6 +690,21 @@ class ApiClient { ); } + /** + * What moving the issue to `status` would do under its project's workflow: + * who it goes to and the previous agent's active runs. Writes nothing. + * Fails closed on a malformed body — the caller shows an error instead of + * applying a move nobody could confirm. + */ + async previewWorkflowHandoff(issueId: string, status: string): Promise { + const raw = await this.fetch( + `/api/issues/${issueId}/workflow-handoff?${new URLSearchParams({ status }).toString()}`, + ); + const parsed = WorkflowHandoffPreviewSchema.safeParse(raw); + if (!parsed.success) throw new Error("workflow handoff preview is malformed"); + return parsed.data as WorkflowHandoffPreview; + } + // Write endpoint — mirrors POST /api/issues // (server/cmd/server/router.go:320, server/internal/handler/issue.go // CreateIssue). Mobile sends only the fields the form fills in; backend diff --git a/apps/mobile/data/mutations/issue-status-binding.test.ts b/apps/mobile/data/mutations/issue-status-binding.test.ts new file mode 100644 index 00000000000..c46ca1cb54f --- /dev/null +++ b/apps/mobile/data/mutations/issue-status-binding.test.ts @@ -0,0 +1,107 @@ +import { QueryClient } from "@tanstack/react-query"; +import type { Issue } from "@multica/core/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { api } from "@/data/api"; +import { issueKeys } from "@/data/queries/issue-keys"; +import { useUpdateIssue } from "./issues"; + +// A project workflow can hand an issue off when it enters a status, and the +// server applies that handoff to any write that does not say what the person +// saw. So the mobile mutation must not send a status without the binding that +// the preview and confirmation produced, whichever screen calls it. + +const state = vi.hoisted(() => ({ + qc: undefined as unknown as QueryClient, +})); + +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useQueryClient: () => state.qc, + useMutation: ( + options: ConstructorParameters[1], + ) => { + const observer = new actual.MutationObserver(state.qc, options); + return { mutateAsync: (variables: unknown) => observer.mutate(variables) }; + }, + }; +}); + +vi.mock("@/data/api", () => ({ + api: { updateIssue: vi.fn(async () => ({ id: "issue-1", revision: 2 })) }, +})); + +vi.mock("@/data/auth-store", () => ({ useAuthStore: vi.fn() })); + +vi.mock("@/data/workspace-store", () => ({ + useWorkspaceStore: ( + selector: (s: { currentWorkspaceId: string }) => unknown, + ) => selector({ currentWorkspaceId: "workspace-1" }), +})); + +const wsId = "workspace-1"; +const issueId = "issue-1"; +const detailKey = issueKeys.detail(wsId, issueId); + +const NO_HANDOFF = { + previous_assignee_type: null, + previous_assignee_id: null, + handler_type: null, + handler_id: null, +}; + +beforeEach(() => { + state.qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + state.qc.setQueryData(detailKey, { id: issueId, status: "todo", revision: 1 } as Issue); + vi.mocked(api.updateIssue).mockClear(); +}); + +describe("useUpdateIssue status writes", () => { + it("refuses a status change that carries no handoff binding", async () => { + const update = useUpdateIssue(issueId); + + await expect(update.mutateAsync({ status: "in_review" })).rejects.toThrow(); + + expect(api.updateIssue).not.toHaveBeenCalled(); + expect(state.qc.getQueryData(detailKey)?.status).toBe("todo"); + }); + + it("sends a status change with the binding the confirmation produced", async () => { + const update = useUpdateIssue(issueId); + const handoff_preview = { ...NO_HANDOFF, previous_assignee_type: "agent", previous_assignee_id: "forge" }; + + await update.mutateAsync({ status: "in_review", handoff_preview }); + + expect(api.updateIssue).toHaveBeenCalledWith(issueId, { status: "in_review", handoff_preview }); + }); + + it("sends a status change with a stop-runs choice and a handler binding", async () => { + const update = useUpdateIssue(issueId); + const handoff_preview = { + previous_assignee_type: "agent", + previous_assignee_id: "forge", + handler_type: "agent", + handler_id: "sentinel", + }; + + await update.mutateAsync({ status: "in_review", stop_previous_assignee_runs: true, handoff_preview }); + + expect(api.updateIssue).toHaveBeenCalledWith(issueId, { + status: "in_review", + stop_previous_assignee_runs: true, + handoff_preview, + }); + }); + + it("writes other fields without a binding", async () => { + const update = useUpdateIssue(issueId); + + await update.mutateAsync({ priority: "high" }); + + expect(api.updateIssue).toHaveBeenCalledWith(issueId, { priority: "high" }); + }); +}); diff --git a/apps/mobile/data/mutations/issues.ts b/apps/mobile/data/mutations/issues.ts index 795c5109aa8..011b34b9bfe 100644 --- a/apps/mobile/data/mutations/issues.ts +++ b/apps/mobile/data/mutations/issues.ts @@ -539,6 +539,13 @@ export function useUpdateIssue(issueId: string) { mutationKey: ["updateIssue", issueId] as const, mutationFn: (patch: UpdateIssueRequest) => api.updateIssue(issueId, patch), onMutate: async (patch) => { + // A project workflow can hand the issue off when it enters a status, and + // the server applies that to any write that does not say what the person + // saw. A status goes out only with the binding the handoff preview and + // confirmation produced (`planStatusChange`), never bare. + if (patch.status !== undefined && patch.handoff_preview === undefined) { + throw new Error("a status change needs the handoff preview it was confirmed against"); + } const key = issueKeys.detail(wsId, issueId); await qc.cancelQueries({ queryKey: key }); const prev = qc.getQueryData(key); @@ -548,6 +555,8 @@ export function useUpdateIssue(issueId: string) { description_base: _descriptionBase, title_base: _titleBase, expected_revision: _expectedRevision, + handoff_preview: _handoffPreview, + stop_previous_assignee_runs: _stopPreviousAssigneeRuns, ...optimisticPatch } = patch; qc.setQueryData(key, { diff --git a/apps/mobile/data/queries/my-issues.ts b/apps/mobile/data/queries/my-issues.ts index f7f1571f88d..f765c6a8937 100644 --- a/apps/mobile/data/queries/my-issues.ts +++ b/apps/mobile/data/queries/my-issues.ts @@ -1,18 +1,10 @@ /** - * "My Issues" list, server-filtered by scope. Mirrors the three scopes web - * exposes in `packages/views/my-issues/components/my-issues-page.tsx:48-65`: - * - assigned: issues where assignee_id = me - * - created: issues where creator_id = me - * - agents: issues where the assignee is an *indirect* extension of me — - * an owned agent, OR a squad I'm a human member of, lead, or - * have an owned agent inside. Driven server-side by the - * `involves_user_id` predicate (see MUL-2397, 2026-05-19). - * Direct member assignment is intentionally EXCLUDED — that's - * the `assigned` scope's meaning. + * Server-filtered My Issues scopes: direct assignments, creations, or involvement + * through owned agents and squad membership. The agents scope excludes direct + * member assignment, which belongs to assigned. * - * Cache key shape is `issueKeys.myList(wsId, scope, filter)` — same prefix - * as web's `packages/core/issues/queries.ts` so a future WS handler can - * invalidate `issueKeys.myAll(wsId)` and reach both clients. + * Mobile owns these query keys and its QueryClient. useMyIssuesRealtime patches + * or invalidates local lists; each client receives its own server events. */ import { queryOptions } from "@tanstack/react-query"; import { api } from "@/data/api"; diff --git a/apps/mobile/data/realtime/chat-ws-updaters.ts b/apps/mobile/data/realtime/chat-ws-updaters.ts index 4986501766a..4038cffe83e 100644 --- a/apps/mobile/data/realtime/chat-ws-updaters.ts +++ b/apps/mobile/data/realtime/chat-ws-updaters.ts @@ -37,9 +37,7 @@ import type { } from "@multica/core/types"; import { chatKeys } from "@/data/queries/chat"; -// ===================================================== // Sessions list (ChatSession[] keyed by wsId) -// ===================================================== export function patchSessionListAfterRename( qc: QueryClient, @@ -90,9 +88,7 @@ export function flipSessionUnread( ); } -// ===================================================== // Messages cache (ChatMessage[] keyed by sessionId) -// ===================================================== /** * Apply `chat:done` to the messages cache. @@ -205,9 +201,7 @@ export async function applyChatQuickActionsToCache( ); } -// ===================================================== // Pending task (ChatPendingTask keyed by sessionId) -// ===================================================== export function seedPendingTaskFromQueued( qc: QueryClient, @@ -296,9 +290,7 @@ export function seedAcceptedPendingTask( invalidatePendingTask(qc, payload.chat_session_id); } -// ===================================================== // Task messages (live timeline, keyed by taskId) -// ===================================================== /** * Append a `task:message` payload into the per-task timeline cache. diff --git a/apps/mobile/data/realtime/issue-ws-updaters.ts b/apps/mobile/data/realtime/issue-ws-updaters.ts index 60ae28f4ef2..984a5d9b27f 100644 --- a/apps/mobile/data/realtime/issue-ws-updaters.ts +++ b/apps/mobile/data/realtime/issue-ws-updaters.ts @@ -1,29 +1,9 @@ /** - * Mobile-owned WS cache patchers for the issue domain. These are pure - * functions over `QueryClient` — no React, no WS plumbing. The hooks in - * `use-issue-realtime.ts` and `use-my-issues-realtime.ts` translate WS - * events into calls into this module. - * - * Why mobile-owned (and not importing from packages/core/issues/ws-updaters): - * - Web's updaters reference `issueKeys` from `packages/core/issues/queries`, - * a different runtime instance than mobile's `data/queries/issue-keys.ts`. - * TanStack Query keys are compared structurally so this would *appear* - * to work, but binding cache mutation to a foreign key factory invites - * drift the moment either side adjusts its key shape. - * - Mobile cache shapes are simpler: no status-bucketed list, no children - * subtree, no childProgress, no label-byIssue cache. A direct mirror - * would be ~120 lines of conditional dead-code paths. - * - * Cache shapes (the design contract here): - * - Issue detail: Issue (keyed by detail(wsId, id)) - * - Issue timeline: TimelineEntry[] (keyed by timeline(wsId, id)) - * ASC oldest-first; new entries inserted at sorted position. - * - My Issues list: Issue[] (keyed by myList(wsId, scope, filter)) - * Multiple list caches per wsId (one per scope/filter combo). - * Patch ALL of them via setQueriesData on myAll(wsId). - * - Workspace list: Issue[] (keyed by list(wsId)) - * Single cache per wsId (no scope/filter in the key — - * filtering happens client-side off the same list). + * Mobile-owned issue cache patchers, called by record and listing subscriptions. + * Use mobile's key factories and cache shapes rather than web's bucketed caches. + * Detail stores an Issue; timelines store chronological TimelineEntry arrays; + * My Issues stores one Issue array per scope/filter and patches all matching keys; + * the workspace list stores a single client-filtered Issue array. */ import type { QueryClient } from "@tanstack/react-query"; import type { @@ -122,9 +102,7 @@ function acceptsRevision(current: number | undefined, incoming: number | undefin return incoming !== undefined && incoming >= current; } -// ===================================================== // Issue detail cache (single Issue per id) -// ===================================================== export function patchIssueDetail( qc: QueryClient, @@ -233,9 +211,7 @@ export function invalidateIssueAfterReconnect( qc.invalidateQueries({ queryKey: issueKeys.tasks(wsId, issueId) }); } -// ===================================================== // Issue timeline (flat TimelineEntry[], ASC oldest-first) -// ===================================================== export function appendTimelineEntry( qc: QueryClient, @@ -385,9 +361,7 @@ export function removeCommentCascade( ); } -// ===================================================== // My Issues list (flat Issue[] across many keys) -// ===================================================== export function patchMyIssuesList( qc: QueryClient, @@ -420,9 +394,7 @@ export function removeFromMyIssuesList( ); } -// ===================================================== // Workspace Issues list (flat Issue[] under list(wsId)) -// ===================================================== export function patchIssuesList( qc: QueryClient, @@ -464,9 +436,7 @@ export function removeFromIssuesList( ); } -// ===================================================== // Reactions -// ===================================================== export function addCommentReaction( qc: QueryClient, @@ -599,9 +569,7 @@ export function removeIssueReaction( ); } -// ===================================================== // Labels -// ===================================================== export function patchIssueLabels( qc: QueryClient, @@ -637,9 +605,7 @@ export function patchIssueLabels( onIssueAuxiliaryRevision(qc, wsId, issueId, issueRevision, "labels"); } -// ===================================================== // Helpers — payload normalization -// ===================================================== /** * Convert a Comment WS payload into a TimelineEntry. The two types share diff --git a/apps/mobile/data/schemas.ts b/apps/mobile/data/schemas.ts index 18aaddb1f9c..afbe16baa44 100644 --- a/apps/mobile/data/schemas.ts +++ b/apps/mobile/data/schemas.ts @@ -238,9 +238,7 @@ export const EMPTY_LIST_PROJECT_RESOURCES_RESPONSE: ListProjectResourcesResponse total: 0, }; -// ===================================================== // Chat (sessions / messages / pending task) -// ===================================================== // Lenient on every field that's purely informational (status enum, timestamps, // agent/creator ids). `.loose()` so server-added fields pass through. The two // fields mobile keys behaviour on — `id` and `chat_session_id` — are required. @@ -363,9 +361,7 @@ export const TaskMessageListSchema = z.array(TaskMessagePayloadSchema).default([ export const EMPTY_TASK_MESSAGE_LIST: TaskMessagePayload[] = []; -// ===================================================== // Search (issues + projects) -// ===================================================== // Mirrors SearchIssueResult / SearchProjectResult in packages/core/types/api.ts. // Web does not currently route search responses through parseWithFallback, so // the schemas live mobile-side. Promote to core when web adopts the same @@ -401,9 +397,7 @@ export const EMPTY_SEARCH_PROJECTS_RESPONSE: SearchProjectsResponse = { projects: [], }; -// ===================================================== // Agent tasks (per-issue runs, active + history) -// ===================================================== // Mirrors AgentTask in packages/core/types/agent.ts. Backend handlers: // GET /api/issues/{id}/active-task → { tasks: AgentTask[] } (may be empty) // GET /api/issues/{id}/task-runs → AgentTask[] @@ -473,9 +467,7 @@ export interface ActiveTasksResponse { export const EMPTY_AGENT_TASK_LIST: AgentTask[] = []; export const EMPTY_ACTIVE_TASKS_RESPONSE: ActiveTasksResponse = { tasks: [] }; -// ===================================================== // User / Workspace / Inbox / Member / Agent -// ===================================================== // Mobile reads these on every cold start (auth → workspaces → inbox → members // → agents form the boot sequence). A schema drift in any of them used to // cascade — getMe failure flushed the user, listWorkspaces failure landed the @@ -494,7 +486,6 @@ export const UserSchema: z.ZodType = z.object({ avatar_url: z.string().nullable().default(null), onboarded_at: z.string().nullable().default(null), onboarding_questionnaire: z.record(z.string(), z.unknown()).default({}), - 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), @@ -512,7 +503,6 @@ export const EMPTY_USER: User = { avatar_url: null, onboarded_at: null, onboarding_questionnaire: {}, - starter_content_state: null, language: null, profile_description: "", timezone: null, diff --git a/apps/mobile/data/workflow-handoff.test.ts b/apps/mobile/data/workflow-handoff.test.ts new file mode 100644 index 00000000000..c4221562238 --- /dev/null +++ b/apps/mobile/data/workflow-handoff.test.ts @@ -0,0 +1,84 @@ +import type { WorkflowHandoffPreview } from "@multica/core/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { api } from "@/data/api"; +import { planStatusChange, previewBinding } from "./workflow-handoff"; + +// Retrospective tests (added with the implementation, not first): they pin the +// plan the status picker route acts on. The fail-closed mutation they feed is +// covered by the earlier red test in mutations/issue-status-binding.test.ts. + +vi.mock("@/data/api", () => ({ api: { previewWorkflowHandoff: vi.fn() } })); + +const HANDOFF: WorkflowHandoffPreview = { + handoff: true, + workflow_name: "Dev", + from_status: "todo", + to_status: "in_review", + handler_type: "agent", + handler_id: "sentinel", + previous_assignee_type: "agent", + previous_assignee_id: "forge", + previous_runs: [{ task_id: "t1", agent_id: "forge", status: "running", started_at: null }], + brief: "", +}; +const NO_HANDOFF: WorkflowHandoffPreview = { ...HANDOFF, handoff: false, handler_type: null, handler_id: null, previous_runs: [] }; + +beforeEach(() => { + vi.mocked(api.previewWorkflowHandoff).mockReset(); +}); + +describe("previewBinding", () => { + it("binds who the issue is taken from and who it goes to", () => { + expect(previewBinding(HANDOFF)).toEqual({ + previous_assignee_type: "agent", + previous_assignee_id: "forge", + handler_type: "agent", + handler_id: "sentinel", + }); + }); + + it("binds a preview that found no handoff to no handler", () => { + expect(previewBinding({ ...NO_HANDOFF, handler_type: "agent", handler_id: "stale" })).toEqual({ + previous_assignee_type: "agent", + previous_assignee_id: "forge", + handler_type: null, + handler_id: null, + }); + }); +}); + +describe("planStatusChange", () => { + it("applies a change the preview found no handoff for, bound to that result", async () => { + vi.mocked(api.previewWorkflowHandoff).mockResolvedValue(NO_HANDOFF); + + const plan = await planStatusChange("issue-1", "in_review"); + + expect(api.previewWorkflowHandoff).toHaveBeenCalledWith("issue-1", "in_review"); + expect(plan).toEqual({ + kind: "apply", + request: { + status: "in_review", + handoff_preview: { previous_assignee_type: "agent", previous_assignee_id: "forge", handler_type: null, handler_id: null }, + }, + }); + }); + + it("asks to confirm a handoff and builds the write for each choice", async () => { + vi.mocked(api.previewWorkflowHandoff).mockResolvedValue(HANDOFF); + + const plan = await planStatusChange("issue-1", "in_review"); + + if (plan.kind !== "confirm") throw new Error("expected a confirmation"); + expect(plan.preview).toBe(HANDOFF); + const handoff_preview = { previous_assignee_type: "agent", previous_assignee_id: "forge", handler_type: "agent", handler_id: "sentinel" }; + expect(plan.request(false)).toEqual({ status: "in_review", handoff_preview }); + expect(plan.request(true)).toEqual({ status: "in_review", handoff_preview, stop_previous_assignee_runs: true }); + }); + + it("rejects, writing nothing, when the preview fails", async () => { + vi.mocked(api.previewWorkflowHandoff).mockRejectedValue(new Error("offline")); + + await expect(planStatusChange("issue-1", "in_review")).rejects.toThrow("offline"); + }); +}); diff --git a/apps/mobile/data/workflow-handoff.ts b/apps/mobile/data/workflow-handoff.ts new file mode 100644 index 00000000000..d622de5d403 --- /dev/null +++ b/apps/mobile/data/workflow-handoff.ts @@ -0,0 +1,59 @@ +import type { + HandoffPreviewBinding, + UpdateIssueRequest, + WorkflowHandoffPreview, +} from "@multica/core/types"; + +import { api } from "@/data/api"; + +/** + * Status changes and project workflows (MUL-7420). Entering a workflow step + * can hand the issue to the step's handler and stop the previous assignee's + * runs, so a status is never written blind: the server previews the change, + * the person confirms what it shows when it hands off, and the write carries + * the binding of what was previewed. The server refuses (409 + * `handoff_preview_stale`) when the issue or its workflow moved since. + * Mirrors `previewBinding` and the status picker in packages/views. + */ + +/** What a preview showed, as the binding the write is checked against. A + * preview that found no handoff binds that result — no handler — so a handler + * that became resolvable since is refused instead of taking the issue. */ +export function previewBinding(preview: WorkflowHandoffPreview): HandoffPreviewBinding { + return { + previous_assignee_type: preview.previous_assignee_type ?? null, + previous_assignee_id: preview.previous_assignee_id ?? null, + handler_type: preview.handoff ? preview.handler_type ?? null : null, + handler_id: preview.handoff ? preview.handler_id ?? null : null, + }; +} + +export type StatusChangePlan = + /** The preview found no handoff: write `request` as is. */ + | { kind: "apply"; request: UpdateIssueRequest } + /** The move hands the issue off: show `preview`, then write `request(stop)`. */ + | { + kind: "confirm"; + preview: WorkflowHandoffPreview; + request: (stopPreviousRuns: boolean) => UpdateIssueRequest; + }; + +/** + * Previews moving the issue to `status` and decides what the write needs. + * Rejects when the preview fails (offline, 400 for a status the workflow omits, + * malformed body): the caller reports it and writes nothing. + */ +export async function planStatusChange(issueId: string, status: string): Promise { + const preview = await api.previewWorkflowHandoff(issueId, status); + const handoff_preview = previewBinding(preview); + if (!preview.handoff) return { kind: "apply", request: { status, handoff_preview } }; + return { + kind: "confirm", + preview, + request: (stopPreviousRuns) => ({ + status, + handoff_preview, + ...(stopPreviousRuns ? { stop_previous_assignee_runs: true } : {}), + }), + }; +} diff --git a/apps/mobile/lib/markdown/markdown-style.ts b/apps/mobile/lib/markdown/markdown-style.ts index 2f3342e506e..7f2a84a7b05 100644 --- a/apps/mobile/lib/markdown/markdown-style.ts +++ b/apps/mobile/lib/markdown/markdown-style.ts @@ -179,46 +179,10 @@ export function useMarkdownStyle() { color: t.brand, underline: true, }, - // Inline code — monospace + muted-foreground tint, NO background chip. - // - // Why no background: on iOS, NSAttributedString draws - // NSBackgroundColorAttributeName as a rectangle spanning the full - // line-box height (24pt for our CJK-tuned body leading). PingFang - // SC places the baseline at ~75-80% of the line-box, so monospaced - // glyphs sit low inside that rectangle, leaving ~11pt empty at the - // top vs ~6pt at the bottom — a visible 2:1 asymmetry in CJK - // paragraphs. This is a platform-level constraint, not a library - // bug: the library's STYLES.md exposes only fontFamily / fontSize / - // color / backgroundColor / borderColor for inline code — no - // padding, lineHeight, or baselineOffset knob. Confirmed 2026-05-21 - // (researcher brief): zero open PRs / forks / prereleases touching - // inline-code vertical geometry; upstream issue #255 has no - // maintainer response since 2026-04-20. The only real fixes are at - // the native layer (custom NSLayoutManager.drawBackground override - // or TextKit 2 NSTextLayoutManager.enumerateTextSegments(.highlight)) - // which would require forking the library — not worth the perpetual - // patch-rebase cost for a polish issue. - // - // Visual identification stays strong without the chip: monospace - // font + muted-foreground tint matches Apple Notes / iA Writer. - // - // Color choice — mutedForeground (NOT brand): - // - mutedForeground is a neutral gray, reads as "subdued prose". - // - brand is reserved for links; tinting code blue confused users - // into tapping it as if it were a link (tried 2026-05-19, then - // reverted same day for this reason). - // - // fontFamily intentionally NOT set: enriched-markdown's default is - // the platform system monospace (SF Mono on iOS, monospace on - // Android), which has a larger visual x-height than the previously- - // explicit "Menlo" override. - // - // Revisit when: - // - upstream #255 ships a paddingVertical / lineHeight knob, OR - // - we contribute the TextKit 2 fix upstream (the right hook is - // NSTextLayoutManager.enumerateTextSegments(in:type:.highlight:)) - // Either way, switch back to a tinted-chip style for cross-platform - // visual parity with web/desktop. + // Keep inline code backgrounds transparent: native attributed-string chips + // span the line box and cannot be vertically padded by this renderer. Monospace + // and a neutral tint identify code without making it look like a link. + // Leave fontFamily unset to use the platform's system monospace. code: { color: t.mutedForeground, backgroundColor: "transparent", diff --git a/apps/mobile/lib/markdown/split-markdown.ts b/apps/mobile/lib/markdown/split-markdown.ts index 307ea994b0e..b723be8115e 100644 --- a/apps/mobile/lib/markdown/split-markdown.ts +++ b/apps/mobile/lib/markdown/split-markdown.ts @@ -1,47 +1,10 @@ /** - * Hybrid-render segment splitter. + * Split Markdown into native prose, React code blocks, and interactive images. + * Use lexer token boundaries so fenced/indented code and paragraph images are + * handled without fragile regex reconstruction. Preserve prose through token.raw. * - * react-native-enriched-markdown gives us native-quality rendering of - * prose (paragraphs / headings / lists / quotes / tables / inline code / - * links / mentions) but does NOT let us inject React for any leaf node - * (see issues #54, #232, #246 — maintainer's stance is "no custom - * renderers, ever, by design"). That blocks three product needs: - * - * 1. Shiki syntax-highlighted code blocks (web parity). - * 2. Tap-to-lightbox on images. - * 3. Horizontal scroll + copy button on code. - * - * Maintainer-endorsed workaround (issue #246): split the markdown at - * those boundaries, render each fragment as its own enriched instance - * for prose and as React for the rest. This file is that splitter. - * - * Strategy: - * - * - Run `marked.lexer` (we already depend on it for preprocess). - * - Walk the top-level token list. - * - `code` token → emit a `code` segment. - * - `paragraph` token containing image tokens → split the paragraph - * at each image boundary. Each image becomes its own `image` - * segment; the surrounding text rejoins the prose stream. (RN's - * can't be inline in a , and tap-to-lightbox needs a - * Pressable wrapper that's not viable inside an attributed string - * anyway. GitHub mobile and Linear iOS both promote inline images - * to block-level — we follow.) - * - Every other block stays in the prose buffer and is handed to - * enriched verbatim via `token.raw`. - * - * Why not regex: code inside lists, indented code, paragraph-embedded - * images, and consecutive code blocks without a blank line all have - * boundaries regex can't reliably detect. marked.lexer already knows; - * reuse it. - * - * Known trade-off: code blocks nested inside a list item stay with the - * list and render via enriched (no Shiki). That's because the splitter - * only inspects top-level tokens — descending into list/quote children - * would force us to also re-serialise their surrounding markdown, - * complicating prose reconstruction. Top-level code is the >95% case - * for issue descriptions / comments; revisit if real content shows - * otherwise. + * Only top-level code is extracted. Nested list/quote code stays in the native + * renderer to preserve its surrounding Markdown structure. */ import { marked, type Tokens } from "marked"; diff --git a/apps/mobile/locales/en/issues.json b/apps/mobile/locales/en/issues.json index b557a22c886..c1510e8d883 100644 --- a/apps/mobile/locales/en/issues.json +++ b/apps/mobile/locales/en/issues.json @@ -267,5 +267,18 @@ "summary.chat": "Chat run", "summary.direct": "Run", "cancel.title": "Cancel run?", - "cancel.cancel_task": "Cancel run" + "cancel.cancel_task": "Cancel run", + "workflow_handoff": { + "check_failed_title": "Couldn't check the handoff", + "check_failed_message": "Nothing was changed. {{message}}", + "retry": "Retry", + "cancel": "Cancel", + "confirm_title": "Move to {{status}}?", + "confirm_message": "This hands the issue to {{handler}}.", + "confirm_message_runs": "This hands the issue to {{handler}} and can stop the previous assignee's active runs.", + "move": "Move", + "move_and_stop": "Move and stop runs", + "failed_title": "Couldn't change the status", + "stale_message": "The issue or its workflow changed since the handoff was previewed. Nothing was changed; try again." + } } diff --git a/apps/mobile/locales/zh-Hans/issues.json b/apps/mobile/locales/zh-Hans/issues.json index 9c95b119319..61f6e091d3b 100644 --- a/apps/mobile/locales/zh-Hans/issues.json +++ b/apps/mobile/locales/zh-Hans/issues.json @@ -266,5 +266,18 @@ "picker.create_label": "创建 \"{{name}}\"", "comments.composer_pill": "添加评论,输入 @ 提及...", "resources.branch_hint": "运行会从这个分支开始,并针对它创建 Pull Request。留空则使用仓库默认分支。", - "resources.commit": "这是提交 ID,不是分支。运行会回传到起始分支。如需一次性修订,请给 multica repo checkout 传入 --ref。" + "resources.commit": "这是提交 ID,不是分支。运行会回传到起始分支。如需一次性修订,请给 multica repo checkout 传入 --ref。", + "workflow_handoff": { + "check_failed_title": "无法确认交接", + "check_failed_message": "没有做任何修改。{{message}}", + "retry": "重试", + "cancel": "取消", + "confirm_title": "移到「{{status}}」?", + "confirm_message": "这会把任务交给 {{handler}}。", + "confirm_message_runs": "这会把任务交给 {{handler}},并可能停止上一位负责人正在进行的运行。", + "move": "移动", + "move_and_stop": "移动并停止运行", + "failed_title": "无法修改状态", + "stale_message": "自预览交接以来,任务或其工作流已变化。没有做任何修改,请重试。" + } } diff --git a/apps/ui-lab/src/product-fixtures.ts b/apps/ui-lab/src/product-fixtures.ts index 3afbe8b8dcc..920cc03b35a 100644 --- a/apps/ui-lab/src/product-fixtures.ts +++ b/apps/ui-lab/src/product-fixtures.ts @@ -34,7 +34,6 @@ export const user: User = { avatar_url: null, onboarded_at: time, onboarding_questionnaire: {}, - starter_content_state: "imported", language: "en", profile_description: "", timezone: "Asia/Shanghai", diff --git a/apps/web/app/(auth)/onboarding/page.tsx b/apps/web/app/(auth)/onboarding/page.tsx index a48b1d1727d..00c9a4e2190 100644 --- a/apps/web/app/(auth)/onboarding/page.tsx +++ b/apps/web/app/(auth)/onboarding/page.tsx @@ -17,7 +17,8 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin * ``. Kept minimal — guard on auth, render, exit. * * Runtime-connected onboarding opens the Mika session that the final step - * created and started. Other exits land on the workspace issues list, or root + * created and started; skipping the runtime opens the install-runtime guide + * issue it created. Other exits land on the workspace issues list, or root * when no workspace exists. * * `CliInstallInstructions` is passed in as the `runtimeInstructions` diff --git a/apps/web/app/[workspaceSlug]/layout.test.tsx b/apps/web/app/[workspaceSlug]/layout.test.tsx index 24cb5ebac51..dca7ef392df 100644 --- a/apps/web/app/[workspaceSlug]/layout.test.tsx +++ b/apps/web/app/[workspaceSlug]/layout.test.tsx @@ -60,10 +60,6 @@ vi.mock("@multica/views/workspace/no-access-page", () => ({ NoAccessPage: () =>
, })); -vi.mock("@multica/views/workspace/welcome-after-onboarding", () => ({ - WelcomeAfterOnboarding: () => null, -})); - vi.mock("@multica/views/workspace/use-workspace-seen", () => ({ useWorkspaceSeen: () => state.hasBeenSeen, })); diff --git a/apps/web/app/[workspaceSlug]/layout.tsx b/apps/web/app/[workspaceSlug]/layout.tsx index e88f010c71b..174ee72be01 100644 --- a/apps/web/app/[workspaceSlug]/layout.tsx +++ b/apps/web/app/[workspaceSlug]/layout.tsx @@ -8,7 +8,6 @@ import { workspaceBySlugOptions } from "@multica/core/workspace"; import { setCurrentWorkspace } from "@multica/core/platform"; import { useAuthStore } from "@multica/core/auth"; import { NoAccessPage } from "@multica/views/workspace/no-access-page"; -import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding"; import { MulticaIcon } from "@multica/ui/components/common/multica-icon"; import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen"; import { workspaceSlugFromPathname } from "@/lib/workspace-slug-from-pathname"; @@ -63,28 +62,10 @@ export default function WorkspaceLayout({ enabled: !!user, }); - // Render-phase sync: feed the URL slug into the platform singleton so - // the first child query's X-Workspace-Slug header is already correct. - // setCurrentWorkspace self-dedupes + runs rehydrate as a side effect; - // safe to call on every render. - // - // Gated on the live pathname because this is a module-global write from - // render, and the App Router can keep a previous workspace's layout mounted - // beside the incoming one. Both instances re-render on their own query - // activity, so an unguarded write let them alternate — every render flipping - // the singleton back to its own slug, indefinitely. Each flip tore down and - // rebuilt the realtime socket (packages/core/realtime/provider.tsx binds it - // to this slug) and re-pointed the @mention lookup, which reads the same - // singleton synchronously and falls back to an empty list when the workspace - // it names has no warm cache (packages/views/editor/extensions/ - // mention-suggestion.tsx). Realtime went dead and the mention list came back - // empty for whichever workspace the user was actually looking at. - // - // Comparing against the pathname resolves it without an ownership handshake: - // the URL is the source of truth for workspace identity, and usePathname() - // is one value shared by every mounted instance, so exactly one layout — the - // routed one — can define it. Desktop needs its own protocol instead - // (workspace-route-layout.tsx) because it has no URL bar to appeal to. + // Sync routed workspace identity during render so the first child request has + // the correct workspace header. Gate this global write on the live pathname: + // App Router can retain old layouts whose rerenders would otherwise switch the + // singleton and realtime connection back to a stale workspace. if (workspace && workspaceSlug === workspaceSlugFromPathname(pathname)) { setCurrentWorkspace(workspaceSlug, workspace.id); } @@ -140,11 +121,6 @@ export default function WorkspaceLayout({ /> )} {children} - {/* Reads the welcome-store transient signal parked by - * OnboardingFlow.handleRuntimeNext. Runtime path → loading veil → - * blocking Modal with Helper + starter cards. Skip path → Modal - * with two seeded issues. No signal → null. */} - ); } diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts index 873108e06b3..a4c2fb49bd2 100644 --- a/apps/web/app/manifest.ts +++ b/apps/web/app/manifest.ts @@ -1,30 +1,12 @@ import type { MetadataRoute } from "next"; /** - * Web app manifest — what makes the mobile web app installable and lets it run - * in its own window instead of a browser tab. + * Installable web manifest. Start at /inbox so session-aware proxy routing + * opens the workspace or login; the marketing host's root would open the landing + * page even for a signed-in user. * - * `start_url` is deliberately NOT "/". The official marketing hosts keep the - * root path on the public site even for a signed-in session (see - * `isOfficialMarketingHost` in proxy.ts), so an installed app pointed at "/" - * would open the landing page. "/inbox" is one of `LEGACY_ROUTE_SEGMENTS`, - * which proxy.ts resolves per session: signed in with a known workspace it - * lands on that workspace's inbox, signed in without one it lands on /login - * (which resolves against the workspace list), and signed out it lands on - * /login too. All three are pinned in manifest.test.ts, because a launcher - * icon has no URL bar to recover from a wrong destination. - * - * The icons under /icons are generated, not hand-drawn. To regenerate after a - * brand change, edit public/icons/icon.svg and run from public/icons: - * - * sips -s format png --resampleHeightWidth 512 512 icon.svg --out icon-maskable-512.png - * sips -s format png --resampleHeightWidth 180 180 icon.svg --out apple-touch-icon.png - * sips -s format png --resampleHeightWidth 512 512 ../../../desktop/build/icon.png --out icon-512.png - * sips -s format png --resampleHeightWidth 192 192 ../../../desktop/build/icon.png --out icon-192.png - * - * The two `any` icons come from the desktop app icon so an installed web app - * and an installed desktop app show the same artwork; the maskable one is - * full-bleed because Android crops it to the launcher's shape. + * Standard icons share desktop artwork. The maskable SVG supplies full-bleed + * artwork for launcher cropping; regenerate raster sizes from those source assets. */ /** Launch path. Exported so manifest.test.ts can run it through the proxy. */ diff --git a/apps/web/components/web-providers.tsx b/apps/web/components/web-providers.tsx index 4d223a77e72..ee7aeb36d4d 100644 --- a/apps/web/components/web-providers.tsx +++ b/apps/web/components/web-providers.tsx @@ -4,7 +4,6 @@ import { useMemo } from "react"; import { CoreProvider } from "@multica/core/platform"; import { createBrowserCookieLocaleAdapter } from "@multica/core/i18n/browser"; import type { LocaleResources, SupportedLocale } from "@multica/core/i18n"; -import { useWelcomeStore } from "@multica/core/onboarding"; import packageJson from "../package.json"; import { WebNavigationProvider } from "@/platform/navigation"; import { WebScrollRestorationProvider } from "@/platform/scroll-restoration"; @@ -73,16 +72,7 @@ export function WebProviders({ wsUrl={wsUrl || deriveWsUrl()} cookieAuth={cookieAuth} onLogin={setLoggedInCookie} - onLogout={() => { - // welcome-store holds the transient post-onboarding signal. Must - // clear on logout so user B logging into the same browser doesn't - // inherit user A's signal and have fire - // listAgents / createIssue against a workspace user B doesn't even - // belong to. The store's own docstring promises this reset; this - // is where it gets wired. - useWelcomeStore.getState().reset(); - clearLoggedInCookie(); - }} + onLogout={clearLoggedInCookie} identity={identity} locale={locale} resources={resources} diff --git a/apps/web/features/landing/components/download/all-platforms.tsx b/apps/web/features/landing/components/download/all-platforms.tsx index 1c3cd0680e9..8c8f7f6ca2f 100644 --- a/apps/web/features/landing/components/download/all-platforms.tsx +++ b/apps/web/features/landing/components/download/all-platforms.tsx @@ -148,9 +148,7 @@ export function AllPlatforms({ ); } -// ------------------------------------------------------------ // Row -// ------------------------------------------------------------ interface RowProps { icon: React.ReactNode; diff --git a/apps/web/features/landing/components/download/hero.tsx b/apps/web/features/landing/components/download/hero.tsx index 0f35eb69662..13028684b41 100644 --- a/apps/web/features/landing/components/download/hero.tsx +++ b/apps/web/features/landing/components/download/hero.tsx @@ -78,9 +78,7 @@ export function DownloadHero({ ); } -// ------------------------------------------------------------ // Content resolver — maps (detect, assets) → CTA props -// ------------------------------------------------------------ interface HeroContent { title: string; @@ -207,9 +205,7 @@ export function resolveContent( }; } -// ------------------------------------------------------------ // Pieces -// ------------------------------------------------------------ function PrimaryCta({ href, diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index a1eb4725b70..ba9a071458d 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -522,6 +522,30 @@ export function createEnDict( fixes: "Bug Fixes", }, entries: [ + { + version: "0.6.3", + date: "2026-10-16", + title: "Per-run cost details, shorter onboarding, revoked access for former members, and steadier runs", + changes: [], + features: [ + "The execution log shows a cost bar for each run, along with its tokens, time, and model.", + "Run details compare runs by cost, tokens, or agent time, with a cumulative view one click away.", + "Expand any run to see its token and cost breakdown.", + ], + improvements: [ + "Skipping runtime setup during onboarding opens the install guide Issue right away.", + ], + fixes: [ + "Members who leave or are removed from a workspace stop getting its notifications and live updates.", + "The agent builder fills in the configuration even when its draft comes back slightly malformed.", + "The agent builder's retry warning stays gone once you close it or edit the form yourself.", + "Tasks picked up but never delivered to your computer go back to the queue instead of getting stuck.", + "Resuming in a local folder no longer replays committed changes as drafts, and keeps your other drafts.", + "Cache cleanup no longer deletes tracked folders like .next, or Git checkouts inside them.", + "Claude Sonnet 5.5 and Haiku 5.5 runs now show an estimated cost.", + "Arabic, Hebrew, and other right-to-left text reads in the right order, even with English mixed in.", + ], + }, { version: "0.6.2", date: "2026-10-13", diff --git a/apps/web/features/landing/i18n/ja.ts b/apps/web/features/landing/i18n/ja.ts index 7b64033f8fc..0541479a22c 100644 --- a/apps/web/features/landing/i18n/ja.ts +++ b/apps/web/features/landing/i18n/ja.ts @@ -279,6 +279,30 @@ export function createJaDict( fixes: "バグ修正", }, entries: [ + { + version: "0.6.3", + date: "2026-10-16", + title: "実行ごとの費用の詳細、短くなったオンボーディング、退出したメンバーのアクセス停止、より安定した実行", + changes: [], + features: [ + "実行ログで実行ごとの費用がバーで表示され、トークン、所要時間、モデルも分かります。", + "実行の詳細で、費用・トークン・所要時間ごとに実行を比べられ、累計表示にも切り替えられます。", + "どの実行も展開すると、トークンと費用の内訳が見られます。", + ], + improvements: [ + "オンボーディングでランタイムの設定をスキップすると、インストールガイドの Issue がすぐ開きます。", + ], + fixes: [ + "ワークスペースを離れた、または外されたメンバーには、通知もリアルタイムの更新も届かなくなります。", + "エージェント ビルダーの下書きの形式が少し崩れていても、設定に反映されます。", + "エージェント ビルダーの再試行の案内は、閉じるか手で編集すると再び表示されません。", + "受け取られたまま届かなかったタスクがキューに戻り、止まったままになりません。", + "ローカルフォルダーで再開しても、コミット済みの変更を下書きとして再適用せず、ほかの下書きも残ります。", + "キャッシュの整理で、リポジトリが管理する .next などのフォルダーや中の Git リポジトリが消えなくなります。", + "Claude Sonnet 5.5 と Haiku 5.5 の実行でも費用の見積もりが表示されます。", + "アラビア語やヘブライ語など右から左に書く文章に英語が混ざっても、正しい順序で読めます。", + ], + }, { version: "0.6.2", date: "2026-10-13", diff --git a/apps/web/features/landing/i18n/ko.ts b/apps/web/features/landing/i18n/ko.ts index 040fd232578..557eff98dd6 100644 --- a/apps/web/features/landing/i18n/ko.ts +++ b/apps/web/features/landing/i18n/ko.ts @@ -278,6 +278,30 @@ export function createKoDict( fixes: "버그 수정", }, entries: [ + { + version: "0.6.3", + date: "2026-10-16", + title: "실행별 비용 상세, 더 짧아진 온보딩, 떠난 멤버의 접근 차단, 더 안정적인 실행", + changes: [], + features: [ + "실행 로그에서 실행마다 비용 막대와 함께 토큰, 소요 시간, 모델을 볼 수 있습니다.", + "실행 상세에서 비용, 토큰, 소요 시간별로 실행을 비교하고 누적 보기로 바꿀 수 있습니다.", + "실행을 펼치면 토큰과 비용 내역을 볼 수 있습니다.", + ], + improvements: [ + "온보딩에서 런타임 설정을 건너뛰면 설치 안내 Issue가 바로 열립니다.", + ], + fixes: [ + "워크스페이스를 떠나거나 제외된 멤버는 알림과 실시간 업데이트를 더 이상 받지 않습니다.", + "에이전트 빌더의 초안 형식이 조금 어긋나도 설정에 채워집니다.", + "에이전트 빌더의 재시도 안내는 닫거나 직접 수정하면 다시 뜨지 않습니다.", + "받아 간 뒤 전달되지 못한 태스크가 대기열로 돌아가 멈춰 있지 않습니다.", + "로컬 폴더에서 이어갈 때 커밋된 변경을 초안으로 다시 적용하지 않고, 다른 초안도 남깁니다.", + "캐시 정리 시 저장소가 추적하는 .next 같은 폴더나 그 안의 Git 저장소를 지우지 않습니다.", + "Claude Sonnet 5.5와 Haiku 5.5 실행도 예상 비용이 표시됩니다.", + "아랍어, 히브리어처럼 오른쪽에서 왼쪽으로 쓰는 글에 영어가 섞여도 올바른 순서로 읽힙니다.", + ], + }, { version: "0.6.2", date: "2026-10-13", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 406b29b9cb6..8b6c526287c 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -507,6 +507,30 @@ export function createZhDict( fixes: "问题修复", }, entries: [ + { + version: "0.6.3", + date: "2026-10-16", + title: "单次运行花费明细、更短的上手引导、离开工作区后的访问撤销、任务运行更稳", + changes: [], + features: [ + "执行记录里每次运行一根花费柱,还能看到 token、耗时和模型。", + "运行详情可以按花费、token 或耗时对比各次运行,也能切回累计视图。", + "展开任意一次运行,就能看到它的 token 和花费明细。", + ], + improvements: [ + "上手时跳过运行时设置,会直接打开安装指南 Issue。", + ], + fixes: [ + "离开或被移出工作区的成员,不再收到该工作区的通知和实时更新。", + "智能体构建器的草稿格式略有出入时,也能填入配置。", + "智能体构建器的重试提示关掉或手动修改后,不会再次弹出。", + "已领取但没送到本机的任务会回到队列,不再卡住。", + "本地目录续接不再把已提交的改动当成草稿重放,其他草稿也会保留。", + "清理缓存时,仓库跟踪的 .next 等目录和其中的 Git 仓库不再被误删。", + "Claude Sonnet 5.5 和 Haiku 5.5 的运行也能估算花费了。", + "阿拉伯语、希伯来语等从右到左的文字夹着英文时,阅读顺序也正确。", + ], + }, { version: "0.6.2", date: "2026-10-13", diff --git a/apps/web/package.json b/apps/web/package.json index 6fa45274f34..59f6a99397f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@multica/web", - "version": "0.6.2", + "version": "0.6.3", "private": true, "type": "module", "scripts": { diff --git a/apps/web/platform/in-app-history.ts b/apps/web/platform/in-app-history.ts index 57fbf178867..a796c04eb81 100644 --- a/apps/web/platform/in-app-history.ts +++ b/apps/web/platform/in-app-history.ts @@ -1,25 +1,9 @@ /** - * "Is there a Multica page behind this one?" for the web navigation adapter. - * - * Callers that want to step back (deleting an issue returns the user to the - * list they opened it from) must not step back off the app when the current - * page was opened cold — a shared link, a pasted URL, a new tab. - * - * Only the browser can answer this, and exactly one API does: the Navigation - * API's `entries()` spans just the contiguous same-origin run around the - * current entry, so arriving from an external site reports `canGoBack` false - * even though the browser technically has somewhere to go. `history.length` - * is no substitute — it counts other origins' entries too. - * - * Nothing else here is a guess. Counting the adapter's own `router.push` - * calls looks like a workable fallback and is not: a push is a request, not a - * committed history entry. Next drops it to `replaceState` when the canonical - * URL is unchanged (`app-router.js`, the `pendingPush && href !== canonicalUrl` - * branch), and pushes can also be superseded or abandoned mid-transition. Any - * count derived from calls can therefore claim history that does not exist, - * and a wrong `true` walks the user out of Multica — the precise failure this - * exists to prevent. So where the browser cannot answer we report `false`, - * and callers take their fallback path. + * Determine whether web navigation has a safe in-app back destination. + * Use Navigation API entries for committed same-origin history. history.length + * includes external entries, and counting router.push requests includes navigations + * that may be replaced or abandoned. Return false when the browser cannot prove + * a destination so callers use their fallback instead of leaving the app. */ /** Minimal shape of the Navigation API — not in TypeScript's DOM lib yet. */ diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx index d1358ea4ba4..6118e1b24fd 100644 --- a/apps/web/test/helpers.tsx +++ b/apps/web/test/helpers.tsx @@ -11,9 +11,6 @@ export const mockUser: User = { avatar_url: null, onboarded_at: "2026-01-01T00:00:00Z", onboarding_questionnaire: {}, - // Matches real server behavior for anyone who onboarded before this - // field shipped — migration 054 backfills 'skipped_legacy'. - starter_content_state: "skipped_legacy", language: null, timezone: null, profile_description: "", diff --git a/packages/core/agents/builder-protocol.test.ts b/packages/core/agents/builder-protocol.test.ts index 0293c508241..a95c5c5005f 100644 --- a/packages/core/agents/builder-protocol.test.ts +++ b/packages/core/agents/builder-protocol.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment node + import { describe, expect, it } from "vitest"; import { decodeBuilderInput, @@ -26,6 +28,19 @@ const draft = (): AgentDraft => ({ }); describe("agent builder protocol", () => { + it.each([ + "[]", + '[{"name":"Reviewer"}]', + '[{"instructions":"First line\nSecond line"}]', + ])("rejects array payloads: %j", (payload) => { + expect( + parseBuilderDraft(`${payload}`), + ).toBeNull(); + expect( + parseBuilderDraft(`${payload}`, { completed: true }), + ).toBeNull(); + }); + it("parses and hides the structured draft block", () => { const content = 'Here is a first draft.\n{"name":"Researcher","permission_scope":"workspace"}'; @@ -293,3 +308,52 @@ Return findings."}`; }); }); }); + +describe("completed builder reply recovery", () => { + const reply = 'Here is your draft.\n{"name":"Poet","instructions":"# Role\\nWrite poetry."}'; + + it("recovers a complete JSON object only after the reply completes", () => { + expect(parseBuilderDraft(reply)).toBeNull(); + expect(parseBuilderDraft(reply, { completed: false })).toBeNull(); + expect(parseBuilderDraft(reply, { completed: true })).toEqual({ + name: "Poet", + instructions: "# Role\nWrite poetry.", + }); + }); + + it("recovers an unclosed completed draft with literal string control characters", () => { + const content = '{"name":"诗仙李白","instructions":"第一行\n\n## 交流方式\r\n- 以第一人称对话。\t保持诗意。","permission_scope":"private"}'; + expect(parseBuilderDraft(content)).toBeNull(); + expect(parseBuilderDraft(content, { completed: false })).toBeNull(); + expect(parseBuilderDraft(content, { completed: true })).toEqual({ + name: "诗仙李白", + instructions: "第一行\n\n## 交流方式\r\n- 以第一人称对话。\t保持诗意。", + permission_scope: "private", + }); + }); + + it.each([ + '{"name":"Poet"', + '{"instructions":"line one\nline two"', + '{"instructions":"line one\nline two"} trailing prose', + '{"name":"Poet",}', + '{"name":"Poet"} trailing prose', + '{"name":"Poet"}{"name":"Other"}', + '[]', + '', + '', + '{"name":"Poet"}null', + '"Poet"', + '{"name":"Poet"}', + ])("rejects malformed or ambiguous completed drafts: %s", (content) => { + expect(parseBuilderDraft(content, { completed: true })).toBeNull(); + }); + + it("preserves closed-block parsing and literal-newline repair", () => { + expect(parseBuilderDraft('{"instructions":"line one\nline two"}')) + .toEqual({ instructions: "line one\nline two" }); + expect(parseBuilderDraft(`${reply}`, { completed: true })) + .toEqual(parseBuilderDraft(`${reply}`)); + }); +}); diff --git a/packages/core/agents/builder-protocol.ts b/packages/core/agents/builder-protocol.ts index d072db9da1d..abfe887495d 100644 --- a/packages/core/agents/builder-protocol.ts +++ b/packages/core/agents/builder-protocol.ts @@ -34,14 +34,32 @@ export interface BuilderDraftPayload { member_ids?: unknown; } -export function parseBuilderDraft(content: string): BuilderDraftPayload | null { +function isBuilderDraftPayload(value: unknown): value is BuilderDraftPayload { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function parseBuilderDraft( + content: string, + { completed = false }: { completed?: boolean } = {}, +): BuilderDraftPayload | null { const match = content.match(/([\s\S]*?)<\/agent_draft>/); - if (!match?.[1]) return null; + if (!match?.[1]) { + // Completed CLI replies can omit the closing tag and include literal + // string control characters. Apply the same repair as closed blocks, + // but never recover a live stream or truncate trailing text. + if (!completed || content.includes("")) return null; + const parts = content.split(""); + if (parts.length !== 2 || !parts[1]) return null; + try { + const value = JSON.parse(escapeJsonStringControlCharacters(parts[1])); + return isBuilderDraftPayload(value) ? value : null; + } catch { + return null; + } + } try { const value = JSON.parse(match[1]); - return value && typeof value === "object" - ? (value as BuilderDraftPayload) - : null; + return isBuilderDraftPayload(value) ? value : null; } catch { // Some CLI-backed models emit literal newlines in the Markdown // instructions string even when asked for compact JSON. Repair only JSON @@ -49,9 +67,7 @@ export function parseBuilderDraft(content: string): BuilderDraftPayload | null { // other syntax still have to pass JSON.parse. try { const value = JSON.parse(escapeJsonStringControlCharacters(match[1])); - return value && typeof value === "object" - ? (value as BuilderDraftPayload) - : null; + return isBuilderDraftPayload(value) ? value : null; } catch { return null; } diff --git a/packages/core/agents/failure-reason.ts b/packages/core/agents/failure-reason.ts index 1f01cdecc62..0875dab9b73 100644 --- a/packages/core/agents/failure-reason.ts +++ b/packages/core/agents/failure-reason.ts @@ -1,25 +1,10 @@ import type { TaskFailureReason } from "../types"; /** - * Picks the copy key a UI surface should render for a server-supplied - * `failure_reason`, degrading instead of falling off a cliff. - * - * Resolution order: - * 1. Exact match — the surface has copy for this precise reason. - * 2. Coarse family — `agent_error.provider_network` degrades to - * `agent_error` when the surface only has family-level copy. - * 3. `undefined` — nothing usable; the caller renders its own fallback. - * - * Why this exists: the backend's taxonomy is refined (`agent_error.*`) and - * grows independently of shipped clients. Before MUL-5370 the surfaces did an - * exact `Record` lookup against the six-value coarse enum, so every refined - * reason missed and rendered a generic "something went wrong" — the useful - * classification the backend had already computed was thrown away at the last - * step. Step 2 makes an unseen `agent_error.` land on the family - * line rather than the fallback, which is the repo's "enum drift downgrades, - * not crashes" rule applied to copy. - * - * Pure and dependency-free so mobile can import it directly. + * Resolve failure copy by exact reason, then coarse family, then caller fallback. + * The server taxonomy can grow independently of installed clients; unfamiliar + * agent_error subtypes should retain family-level guidance. Keep this helper pure + * so mobile can share it. */ export function resolveFailureReasonKey( reason: TaskFailureReason | string | null | undefined, diff --git a/packages/core/agents/stored-draft.test.ts b/packages/core/agents/stored-draft.test.ts index 2b253c34f28..a560e54d7c5 100644 --- a/packages/core/agents/stored-draft.test.ts +++ b/packages/core/agents/stored-draft.test.ts @@ -1,4 +1,7 @@ +// @vitest-environment node + import { describe, expect, it } from "vitest"; +import { StoredAgentDraftSchema } from "../api/schemas"; import type { AgentDraft } from "./draft"; import { fromStoredAgentDraft, @@ -54,6 +57,27 @@ describe("stored agent draft", () => { expect(toStoredAgentDraft(draft(), null).applied_message_id).toBeNull(); }); + it("stores dismissed notices separately from the applied draft", () => { + const stored = toStoredAgentDraft(draft(), "applied", "dismissed"); + expect(StoredAgentDraftSchema.parse(stored)).toMatchObject({ + applied_message_id: "applied", + dismissed_message_id: "dismissed", + }); + expect(fromStoredAgentDraft(stored, "runtime-1")).toEqual(draft()); + expect(storedAgentDraftsEqual(stored, toStoredAgentDraft(draft(), "applied"))).toBe(false); + }); + + it("accepts older drafts and ignores malformed dismissal markers", () => { + const stored = toStoredAgentDraft(draft(), "applied"); + expect(stored).not.toHaveProperty("dismissed_message_id"); + expect(StoredAgentDraftSchema.parse(stored).dismissed_message_id).toBeUndefined(); + expect(StoredAgentDraftSchema.parse({ ...stored, dismissed_message_id: 42 })).toMatchObject({ + name: stored.name, + applied_message_id: "applied", + dismissed_message_id: null, + }); + }); + // Autosave compares on the stored form: a Set rebuilt with the same members // is a new reference every render and would otherwise write on every keystroke // elsewhere in the form. diff --git a/packages/core/agents/stored-draft.ts b/packages/core/agents/stored-draft.ts index e0ee16fc264..827b8808553 100644 --- a/packages/core/agents/stored-draft.ts +++ b/packages/core/agents/stored-draft.ts @@ -6,6 +6,7 @@ export type { StoredAgentDraft }; export function toStoredAgentDraft( draft: AgentDraft, appliedMessageId: string | null, + dismissedMessageId: string | null = null, ): StoredAgentDraft { return { name: draft.name, @@ -21,6 +22,7 @@ export function toStoredAgentDraft( member_ids: [...draft.memberIds], team_ids: [...draft.teamIds], applied_message_id: appliedMessageId, + ...(dismissedMessageId ? { dismissed_message_id: dismissedMessageId } : {}), }; } diff --git a/packages/core/agents/types.ts b/packages/core/agents/types.ts index 3bb23ef5628..a306c928ee0 100644 --- a/packages/core/agents/types.ts +++ b/packages/core/agents/types.ts @@ -1,27 +1,6 @@ -// Derived presence types for agents — the user-facing state we display -// across the UI (list dots, hover cards, status lines). Computed in the -// front-end from raw server data (agent + runtime + recent tasks); the -// back-end never knows about these enums. -// -// Two orthogonal dimensions, derived independently and answering only -// "what's true right now?" — historical / error context lives on the -// agent detail page (Recent Work, failure_reason) and Inbox, not in the -// list-level summary state: -// -// 1. AgentAvailability — "Can this agent take work right now?" -// Depends only on runtime reachability. The dot colour everywhere in -// the app reflects this single dimension; never sticky-red because of -// a past task outcome. -// -// 2. Workload — "What is on this agent's plate right now?" -// Depends only on the workspace task snapshot. Three states, each -// pointing at a clear user action: -// working → tasks running, normal -// queued → tasks queued but nothing running (= stuck if availability -// is offline/unstable; momentary if online) -// idle → nothing to do -// No `failed` / `completed` / `cancelled` states — those are historical, -// surfaced via Recent Work + Inbox. +// Agent presence has two independent current-state dimensions: runtime +// availability and active task workload. Past failures and completed tasks belong +// in Recent Work and Inbox rather than persisting as a list-level error state. // Runtime-reachability dimension. `unstable` is the transient amber state // during the runtime sweeper's grace window (offline < 5 min); it decays diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 7d9528761ae..ba569d61389 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -11,6 +11,7 @@ import type { CreateIssueRequest, MoveIssueRequest, UpdateIssueRequest, + BatchUpdateIssuesResponse, IssueDuplicates, GroupedIssuesResponse, ListIssuesResponse, @@ -138,6 +139,13 @@ import type { IssueStatusEntry, CreateIssueStatusRequest, UpdateIssueStatusRequest, + IssueWorkflow, + IssueWorkflowWriteRequest, + IssueWorkflowDryRunResponse, + ListIssueWorkflowsResponse, + SetProjectWorkflowRequest, + SetWorkflowHandoffRequest, + WorkflowHandoffPreview, IssueLabelsResponse, LabelResourceType, ResourceLabelsResponse, @@ -425,6 +433,13 @@ import { ListLabelsResponseSchema, ListIssueStatusesResponseSchema, IssueStatusEntrySchema, + ListIssueWorkflowsResponseSchema, + IssueWorkflowSchema, + IssueWorkflowDryRunResponseSchema, + EMPTY_ISSUE_WORKFLOW, + EMPTY_ISSUE_WORKFLOW_DRY_RUN, + WorkflowHandoffPreviewSchema, + IssueWorkflowBriefPreviewSchema, IssuePropertySchema, ListPropertiesResponseSchema, IssuePropertiesResponseSchema, @@ -1478,7 +1493,10 @@ export class ApiClient { return issue; } - async createIssue(data: CreateIssueRequest): Promise { + async createIssue( + data: CreateIssueRequest, + workspaceSlug?: string, + ): Promise { const requestedProperties = requestedIssueCreateProperties(data); if (requestedProperties) { const config = await this.getConfig(); @@ -1499,6 +1517,7 @@ export class ApiClient { // render its localized "failed to create" toast. const raw = await this.fetch("/api/issues", { method: "POST", + headers: workspaceHeader(workspaceSlug), body: JSON.stringify(data), }); const issue = parseWithFallback(raw, CreateIssueResponseSchema, null, { @@ -1689,7 +1708,7 @@ export class ApiClient { await this.fetch(`/api/issues/${id}`, { method: "DELETE" }); } - async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise<{ updated: number }> { + async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise { return this.fetch("/api/issues/batch-update", { method: "POST", body: JSON.stringify({ issue_ids: issueIds, updates }), @@ -2129,12 +2148,10 @@ export class ApiClient { }); } - // --------------------------------------------------------------------- // Cloud Billing — proxies to multica-cloud /api/v1/billing/*. The // multica-api server stamps X-User-ID and forwards bytes; everything // here is upstream-shaped. See packages/core/types/billing.ts for the // response field documentation. - // --------------------------------------------------------------------- async getCloudBillingBalance(): Promise { const raw = await this.fetch("/api/cloud-billing/balance"); @@ -2258,7 +2275,6 @@ export class ApiClient { ); } - // --------------------------------------------------------------------- // Workspace subscriptions — the server resolves the workspace from the // authenticated request context, so no caller names one. // @@ -2268,7 +2284,6 @@ export class ApiClient { // - non-2xx (older cloud without the route, 403, 503) throws ApiError from // `fetch`, so a React Query caller sees `isError`; // - a 2xx body that does not match the contract returns null here. - // --------------------------------------------------------------------- async getWorkspaceSubscriptionSummary(): Promise { const raw = await this.fetch("/api/cloud-subscriptions/summary"); @@ -2450,10 +2465,8 @@ export class ApiClient { }); } - // --------------------------------------------------------------------- // Custom runtime profiles (MUL-3284). All workspace-scoped: the caller // passes the workspace id the same way the runtimes list resolves it. - // --------------------------------------------------------------------- async listRuntimeProfiles(workspaceId: string): Promise { const res = await this.fetch<{ runtime_profiles?: RuntimeProfile[] }>( @@ -2595,12 +2608,10 @@ export class ApiClient { ); } - // --------------------------------------------------------------------------- // Workspace dashboard — three independent rollups for `/{slug}/dashboard`. // Each accepts an optional `project_id` to narrow the scope to one project. // Cost is computed client-side from the model pricing table (same contract // as the per-runtime endpoints above). - // --------------------------------------------------------------------------- async getDashboardUsageDaily( params: { days?: number; project_id?: string | null; tz?: string }, @@ -4303,6 +4314,143 @@ export class ApiClient { await this.fetch(`/api/labels/${id}`, { method: "DELETE" }); } + // Workspace workflows (MUL-7420). Reads are open to any workspace member; + // creating, editing and deleting a workflow is owner/admin only. Adoption + // (creating a workflow, pointing a project at one) is gated server-side by + // the project_workflows_v1 flag and returns 403 when it is off. + async listIssueWorkflows(): Promise { + const raw = await this.fetch(`/api/issue-workflows`); + // An empty list is an answer ("no workflow is stored"), and callers let a + // status change skip its handoff confirmation on it. Fail closed on a body + // that is not a workflow list instead of falling back to an empty one. + const list = ListIssueWorkflowsResponseSchema.safeParse(raw); + if (!list.success || !Array.isArray((raw as { workflows?: unknown } | null)?.workflows)) { + throw new Error("malformed GET /api/issue-workflows response"); + } + return list.data as ListIssueWorkflowsResponse; + } + + async createIssueWorkflow(data: IssueWorkflowWriteRequest): Promise { + const raw = await this.fetch(`/api/issue-workflows`, { + method: "POST", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, IssueWorkflowSchema, EMPTY_ISSUE_WORKFLOW, { + endpoint: "POST /api/issue-workflows", + }); + } + + /** + * Saves a workflow edit. When the edit removes statuses that issues of the + * workflow's projects still use, the server answers 409 + * `workflow_status_mapping_required` with a plan; resend with + * `status_mapping`. `dryRunIssueWorkflowUpdate` fetches that plan up front. + */ + async updateIssueWorkflow(id: string, data: IssueWorkflowWriteRequest): Promise { + const raw = await this.fetch(`/api/issue-workflows/${id}`, { + method: "PATCH", + body: JSON.stringify({ ...data, dry_run: false }), + }); + return parseWithFallback(raw, IssueWorkflowSchema, EMPTY_ISSUE_WORKFLOW, { + endpoint: "PATCH /api/issue-workflows/{id}", + }); + } + + async dryRunIssueWorkflowUpdate(id: string, data: IssueWorkflowWriteRequest): Promise { + const raw = await this.fetch(`/api/issue-workflows/${id}`, { + method: "PATCH", + body: JSON.stringify({ ...data, dry_run: true }), + }); + return parseWithFallback(raw, IssueWorkflowDryRunResponseSchema, EMPTY_ISSUE_WORKFLOW_DRY_RUN, { + endpoint: "PATCH /api/issue-workflows/{id} (dry run)", + }); + } + + async deleteIssueWorkflow(id: string): Promise { + await this.fetch(`/api/issue-workflows/${id}`, { method: "DELETE" }); + } + + /** + * Renders the brief a column's handler would receive, from an unsaved + * handoff. `project_id` names the project the preview is for, if any. + */ + async previewWorkflowBrief( + workflowId: string, + data: SetWorkflowHandoffRequest & { status_key: string; project_id?: string }, + ): Promise { + const raw = await this.fetch(`/api/issue-workflows/${workflowId}/brief`, { + method: "POST", + body: JSON.stringify(data), + }); + const parsed = parseWithFallback(raw, IssueWorkflowBriefPreviewSchema, { brief: "" }, { + endpoint: "POST /api/issue-workflows/{id}/brief", + }); + return parsed.brief; + } + + /** + * Saves a workflow's handoff for one column, for every project using it; + * handler "none" clears it. With `copyForProjectId` the workflow stays as + * it is: the handoff lands on a copy that project switches to, and the copy + * is returned. Admin only. + */ + async setWorkflowHandoff( + workflowId: string, + statusKey: string, + data: SetWorkflowHandoffRequest, + copyForProjectId?: string, + ): Promise { + const body = copyForProjectId ? { ...data, copy_for_project_id: copyForProjectId } : data; + const raw = await this.fetch( + `/api/issue-workflows/${workflowId}/handoffs/${encodeURIComponent(statusKey)}`, + { method: "PUT", body: JSON.stringify(body) }, + ); + return parseWithFallback(raw, IssueWorkflowSchema, EMPTY_ISSUE_WORKFLOW, { + endpoint: "PUT /api/issue-workflows/{id}/handoffs/{status}", + }); + } + + /** + * What moving an issue to `status` would do under its project's workflow: + * who it goes to, the current agent's active runs, and the brief. Writes + * nothing; a status outside the workflow answers 400. With `projectId` it + * previews moving the issue into that project instead, entering the column + * it lands on (`status` may then be empty). + */ + async previewWorkflowHandoff(issueId: string, status: string, projectId?: string): Promise { + const query = new URLSearchParams(); + if (status) query.set("status", status); + if (projectId) query.set("project_id", projectId); + const raw = await this.fetch(`/api/issues/${issueId}/workflow-handoff?${query.toString()}`); + const parsed = parseWithFallback(raw, WorkflowHandoffPreviewSchema, null, { + endpoint: "GET /api/issues/{id}/workflow-handoff", + }); + // Fail closed: the caller shows an error and a retry instead of applying a move. + if (!parsed) throw new Error("workflow handoff preview is malformed"); + return parsed; + } + + /** + * Switches a project's workflow; issues on unlisted statuses need a mapping. + * Callers refetch the project and its issues, so the response is not read. + */ + async setProjectWorkflow(projectId: string, data: SetProjectWorkflowRequest): Promise { + await this.fetch(`/api/projects/${projectId}/workflow`, { + method: "POST", + body: JSON.stringify({ ...data, dry_run: false }), + }); + } + + async dryRunProjectWorkflow(projectId: string, workflowId: string | null): Promise { + const raw = await this.fetch(`/api/projects/${projectId}/workflow`, { + method: "POST", + body: JSON.stringify({ workflow_id: workflowId, dry_run: true }), + }); + return parseWithFallback(raw, IssueWorkflowDryRunResponseSchema, EMPTY_ISSUE_WORKFLOW_DRY_RUN, { + endpoint: "POST /api/projects/{id}/workflow (dry run)", + }); + } + // Issue status catalog (MUL-6243). Reads are open to any workspace member; // the mutations below are owner/admin only and return 403 otherwise. async listIssueStatuses(includeArchived = false): Promise { diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index f87da1ee571..4afe7852977 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import { normalizeIssueStatusCategory } from "../issues/config/status"; import type { + WorkflowHandoffPreview, + IssueWorkflow, + IssueWorkflowDryRunResponse, + IssueWorkflowHandlerType, AgentBuilderRuntimeSwitch, AgentBuilderSession, AgentBuilderSessionSummary, @@ -539,6 +543,115 @@ export const EMPTY_LIST_ISSUE_STATUSES_RESPONSE: ListIssueStatusesResponse = { total: 0, }; +// Workspace workflows (MUL-7420). Handler types parse as plain strings and an +// unknown one degrades to "none": a newer server may add a handler this build +// cannot render, and the step's status must still show. +const IssueWorkflowHandlerSchema = z.object({ + type: z.string().transform((value): IssueWorkflowHandlerType => + ISSUE_WORKFLOW_HANDLER_TYPES.has(value) ? (value as IssueWorkflowHandlerType) : "none", + ), + id: z.string().optional(), +}).loose(); + +const ISSUE_WORKFLOW_HANDLER_TYPES = new Set([ + "none", "agent", "squad", "member", "project_lead", "creator", +]); + +const IssueWorkflowStepSchema = z.object({ + status_key: z.string(), + handler: IssueWorkflowHandlerSchema.default({ type: "none" }), + instructions: z.string().default(""), + next_status_key: z.string().optional(), + back_status_key: z.string().optional(), +}).loose(); + +export const IssueWorkflowSchema = z.object({ + id: z.string(), + workspace_id: z.string(), + name: z.string(), + description: z.string().default(""), + is_default: z.boolean().default(false), + steps: z.array(IssueWorkflowStepSchema).default([]), + project_ids: z.array(z.string()).default([]), + created_at: z.string(), + updated_at: z.string(), + // Full-precision updated_at; a save names the version it edited. Absent from + // an older server, which then keeps last-writer-wins. + version: z.string().default(""), +}).loose(); + +export const ListIssueWorkflowsResponseSchema = z.object({ + workflows: z.array(IssueWorkflowSchema).default([]), + total: z.number().default(0), +}).loose(); + +export const EMPTY_ISSUE_WORKFLOW: IssueWorkflow = { + id: "", + workspace_id: "", + name: "", + description: "", + is_default: false, + steps: [], + project_ids: [], + created_at: "", + updated_at: "", + version: "", +}; + +export const IssueWorkflowMappingPlanSchema = z.object({ + required: z.array(z.object({ + status_key: z.string(), + issue_count: z.number().default(0), + suggested_status_key: z.string().default(""), + }).loose()).default([]), + unchanged: z.array(z.object({ + status_key: z.string(), + issue_count: z.number().default(0), + }).loose()).default([]), + total_issues: z.number().default(0), + affected_issues: z.number().default(0), +}).loose(); + +export const IssueWorkflowDryRunResponseSchema = z.object({ + dry_run: z.literal(true).default(true), + plan: IssueWorkflowMappingPlanSchema.default({ required: [], unchanged: [], total_issues: 0, affected_issues: 0 }), +}).loose(); + +export const EMPTY_ISSUE_WORKFLOW_DRY_RUN: IssueWorkflowDryRunResponse = { + dry_run: true, + plan: { required: [], unchanged: [], total_issues: 0, affected_issues: 0 }, +}; + +export const IssueWorkflowBriefPreviewSchema = z.object({ + brief: z.string().default(""), +}).loose(); + +const HANDOFF_HANDLER_TYPES = new Set(["agent", "squad", "member"]); + +// A preview the client cannot read is an error, never "no handoff": the dialog +// applies a no-handoff preview on its own, and the server then applies the +// workflow's handoff with no confirmation shown. The server always sends +// `handoff`, so a missing or mistyped flag is malformed. +export const WorkflowHandoffPreviewSchema = z.object({ + handoff: z.boolean(), + workflow_name: z.string().default(""), + from_status: z.string().default(""), + to_status: z.string().default(""), + handler_type: z.string().optional().transform((value): WorkflowHandoffPreview["handler_type"] => + value && HANDOFF_HANDLER_TYPES.has(value) ? (value as "agent" | "squad" | "member") : null, + ), + handler_id: z.string().nullable().optional().transform((value) => value || null), + previous_assignee_type: z.string().nullable().default(null), + previous_assignee_id: z.string().nullable().default(null), + previous_runs: z.array(z.object({ + task_id: z.string(), + agent_id: z.string(), + status: z.string().default(""), + started_at: z.string().nullable().default(null), + }).loose()).default([]), + brief: z.string().default(""), +}).loose(); + export const ResourceLabelsResponseSchema = z.object({ labels: z.array(LabelSchema).default([]), issue_revision: z.number().int().positive().optional(), @@ -785,7 +898,6 @@ export interface AppConfigResponse { build?: string; } -// --------------------------------------------------------------------------- // Schemas for the highest-risk API endpoints — those whose responses drive // the issue detail page (timeline, comments, subscribers) and the issues // list. These are the surfaces that white-screened in #2143 / #2147 / #2192. @@ -811,7 +923,6 @@ export interface AppConfigResponse { // literal unions, which would defeat the leniency above. `parseWithFallback` // returns the parsed value cast to the caller-supplied `T`, so the strict // type still flows out at the call site; the schema only guards shape. -// --------------------------------------------------------------------------- const ReactionSchema = z.object({ id: z.string(), @@ -1459,6 +1570,7 @@ const ProjectSchema = z.object({ issue_count: z.number().default(0), done_count: z.number().default(0), resource_count: z.number().default(0), + workflow_id: z.string().nullable().default(null), }).loose(); const SearchProjectResultSchema = ProjectSchema.extend({ @@ -1719,7 +1831,6 @@ export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = { updated_at: "", }; -// --------------------------------------------------------------------------- // Workspace dashboard schemas // // The dashboard hits three independent rollup endpoints. Each returns a flat @@ -1728,7 +1839,6 @@ export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = { // 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. -// --------------------------------------------------------------------------- // Cost split carried by every usage row. `cost_usd_ticks` is what the provider // itself charged for the rows behind this aggregate (1e-10 USD); the @@ -1828,12 +1938,10 @@ export const DashboardFailureByAgentListSchema = z.array( DashboardFailureByAgentSchema, ); -// --------------------------------------------------------------------------- // 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(""), @@ -1883,10 +1991,8 @@ const RuntimeUsageByHourSchema = z.object({ export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema); -// --------------------------------------------------------------------------- // Agent task responses. The base object stays loose so daemon/runtime fields // can drift while task-list consumers still validate the fields they render. -// --------------------------------------------------------------------------- // Human attribution (MUL-4302 §9): who an agent run is accountable to, and how // that human was resolved. Every field is defensive so a departed member, an @@ -2339,6 +2445,7 @@ export const StoredAgentDraftSchema = z.object({ member_ids: z.array(z.string()).catch([]), team_ids: z.array(z.string()).catch([]), applied_message_id: z.string().nullable().catch(null), + dismissed_message_id: z.string().nullish().catch(null), }).loose(); /** @@ -2454,7 +2561,6 @@ export const SquadMemberStatusListResponseSchema = z.object({ export const EMPTY_SQUAD_MEMBER_STATUS_LIST = { members: [] }; -// --------------------------------------------------------------------------- // Structured error body — POST /api/workspaces/:wsId/issues 409 conflict. // // When the server detects an active issue with the same title in the same @@ -2474,7 +2580,6 @@ export const EMPTY_SQUAD_MEMBER_STATUS_LIST = { members: [] }; // so a future server-side rename of `status` must not knock this branch // out. `.loose()` lets the field pass through unchanged for any other // consumer. -// --------------------------------------------------------------------------- export const DuplicateIssueErrorBodySchema = z.object({ code: z.literal("active_duplicate_issue"), @@ -2496,14 +2601,12 @@ export interface DuplicateIssueErrorBody { }; } -// --------------------------------------------------------------------------- // Webhook delivery schemas — backing the Autopilot Deliveries section. Enums // (`status`, `signature_status`, `provider`) are kept as `z.string()` so a // future server-side value (e.g. a Stripe provider, a new dedupe state) // degrades to a generic UI fallback rather than collapsing the list into // the empty array. `.loose()` lets unknown fields pass through, matching // the rule used by every other endpoint here. -// --------------------------------------------------------------------------- const WebhookDeliverySchema = z.object({ id: z.string(), @@ -2550,13 +2653,11 @@ export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesRespon total: 0, }; -// --------------------------------------------------------------------------- // Autopilot list schema. Enums (`status`, `execution_mode`, `trigger_kinds`, // `last_run_status`) stay `z.string()` so future server-side values degrade // to a generic UI fallback. The three derived fields (trigger_kinds / // next_run_at / last_run_status) are list-endpoint-only and absent on older // servers — optional by contract, the list renders "—" without them. -// --------------------------------------------------------------------------- const AutopilotListItemSchema = z.object({ id: z.string(), @@ -2685,7 +2786,6 @@ export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = { created_at: "", }; -// --------------------------------------------------------------------------- // User (`/api/me` GET + PATCH). The auth store and Settings → Account both // trust this shape — a drift here would knock both surfaces out. Kept // lenient by the same rules as IssueSchema: enums stay `z.string()`, @@ -2694,7 +2794,6 @@ export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = { // MUL-2406; the server emits `""` when unset (NOT NULL DEFAULT ''), so // the schema defaults to `""` too — keeps the type tight without // breaking older backends that don't return the column yet. -// --------------------------------------------------------------------------- export const UserSchema = z.object({ id: z.string(), @@ -2703,7 +2802,6 @@ export const UserSchema = z.object({ avatar_url: z.string().nullable().default(null), onboarded_at: z.string().nullable().default(null), onboarding_questionnaire: z.record(z.string(), z.unknown()).default({}), - 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), @@ -2718,7 +2816,6 @@ export const EMPTY_USER: User = { avatar_url: null, onboarded_at: null, onboarding_questionnaire: {}, - starter_content_state: null, language: null, profile_description: "", timezone: null, @@ -2726,13 +2823,11 @@ export const EMPTY_USER: User = { updated_at: "", }; -// --------------------------------------------------------------------------- // Cross-workspace unread inbox summary (`/api/inbox/unread-summary` GET). // One entry per workspace the user belongs to that has unread items; the // sidebar derives the workspace-switcher dot from it. Lenient per the usual // rules so a future field addition can't blank the dot — on malformed JSON // parseWithFallback returns the empty list, which simply hides the dot. -// --------------------------------------------------------------------------- export const InboxUnreadSummarySchema = z.array( z @@ -2745,7 +2840,6 @@ export const InboxUnreadSummarySchema = z.array( export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = []; -// --------------------------------------------------------------------------- // Inbox items (`/api/inbox` and `/api/inbox/archived` GET). // Lenient per the usual rules: `severity` / `type` / `recipient_type` stay // `z.string()` so a notification kind this client doesn't know yet still @@ -2755,7 +2849,6 @@ export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = []; // empty list — the affected view then reads as empty rather than white- // screening the inbox. Both endpoints share this boundary because they return // the same row shape and both feed the status/priority filter UI. -// --------------------------------------------------------------------------- export const InboxItemListSchema = z.array( z @@ -2802,7 +2895,6 @@ export const ArchivedInboxFacetsSchema = z.object({ export const EMPTY_INBOX_ITEMS: InboxItem[] = []; -// --------------------------------------------------------------------------- // Billing schemas (cloud-billing proxy surface) // // All billing JSON we receive comes from multica-cloud verbatim — we proxy @@ -2972,7 +3064,6 @@ export const EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE: CreateBillingPortalSe url: "", }; -// --------------------------------------------------------------------------- // Workspace subscriptions (`/api/cloud-subscriptions/*`) // // These schemas are the compatibility boundary with multica-cloud. Three rules @@ -3268,7 +3359,6 @@ export const CreateWorkspaceSubscriptionPortalResponseSchema = z }), ); -// --------------------------------------------------------------------------- // Runtime model discovery (`POST /api/runtimes/:id/models`, // `GET /api/runtimes/:id/models/:requestId`). Both endpoints return the same // request record, and the UI drives a state machine off `status`, so the two @@ -3284,7 +3374,6 @@ export const CreateWorkspaceSubscriptionPortalResponseSchema = z // // `cached` / `cached_at` are additive markers for a snapshot served from the // server-side catalog cache (MUL-5444); an older backend omits them. -// --------------------------------------------------------------------------- const RuntimeModelThinkingLevelSchema = z.object({ value: z.string(), diff --git a/packages/core/api/workflow-handoff-preview.test.ts b/packages/core/api/workflow-handoff-preview.test.ts new file mode 100644 index 00000000000..66c356d7a6b --- /dev/null +++ b/packages/core/api/workflow-handoff-preview.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiClient } from "./client"; + +afterEach(() => vi.unstubAllGlobals()); + +function stubFetch(body: unknown) { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const preview = () => new ApiClient("https://api.example.test").previewWorkflowHandoff("issue-1", "in_review"); + +// The preview is what asks the person to confirm a handoff. A successful +// response the client cannot read must not turn into "nothing to confirm": the +// dialog applies a no-handoff preview on its own, and the server then applies +// the workflow's handoff with no confirmation shown. +describe("previewWorkflowHandoff", () => { + it.each([ + ["a wrong-typed handoff flag", { handoff: "yes", previous_runs: 3 }], + ["a missing handoff flag", { workflow_name: "Delivery", to_status: "in_review" }], + ["an empty object", {}], + ["null", null], + ["a list", []], + ])("rejects %s instead of reading it as no handoff", async (_name, body) => { + stubFetch(body); + await expect(preview()).rejects.toThrow(); + }); + + it("reads a valid response without a handoff as no handoff", async () => { + stubFetch({ handoff: false }); + const result = await preview(); + expect(result.handoff).toBe(false); + }); + + it("reads a valid handoff response with its handler", async () => { + stubFetch({ + handoff: true, + workflow_name: "Delivery", + to_status: "in_review", + handler_type: "agent", + handler_id: "agent-1", + previous_runs: [{ task_id: "t1", agent_id: "agent-0", status: "running", started_at: null }], + }); + const result = await preview(); + expect(result.handoff).toBe(true); + expect(result.handler_id).toBe("agent-1"); + expect(result.previous_runs).toHaveLength(1); + }); +}); diff --git a/packages/core/attachments/image-sequence.ts b/packages/core/attachments/image-sequence.ts index c6ccf620c7d..c16c21f016b 100644 --- a/packages/core/attachments/image-sequence.ts +++ b/packages/core/attachments/image-sequence.ts @@ -72,9 +72,7 @@ export function isImageAttachment( return ext !== "" && IMAGE_EXTENSIONS.has(ext); } -// --------------------------------------------------------------------------- // URL → attachment matching -// --------------------------------------------------------------------------- function stripQueryAndFragment(url: string): string { return url.split(/[?#]/, 1)[0] ?? ""; @@ -177,9 +175,7 @@ export function orderStandaloneAttachments a.index - b.index); } -// --------------------------------------------------------------------------- // Sequence -// --------------------------------------------------------------------------- /** One attachment the viewer can page to. */ export interface ImageSequenceItem { diff --git a/packages/core/feature-flags/index.ts b/packages/core/feature-flags/index.ts index 4683dcb6678..c54f183f729 100644 --- a/packages/core/feature-flags/index.ts +++ b/packages/core/feature-flags/index.ts @@ -21,6 +21,7 @@ export { BILLING_WORKSPACE_SUBSCRIPTIONS_FLAG, COMPOSIO_MCP_APPS_FLAG, PLUGINS_V1_FLAG, + PROJECT_WORKFLOWS_V1_FLAG, } from "./keys"; export { FeatureFlagsProvider, diff --git a/packages/core/feature-flags/keys.ts b/packages/core/feature-flags/keys.ts index db0dd7123d2..957a3bfe23d 100644 --- a/packages/core/feature-flags/keys.ts +++ b/packages/core/feature-flags/keys.ts @@ -2,3 +2,4 @@ export const COMPOSIO_MCP_APPS_FLAG = "composio_mcp_apps"; export const PLUGINS_V1_FLAG = "plugins_v1"; export const BILLING_WORKSPACE_SUBSCRIPTIONS_FLAG = "billing_workspace_subscriptions"; +export const PROJECT_WORKFLOWS_V1_FLAG = "project_workflows_v1"; diff --git a/packages/core/github/pull-request-status.ts b/packages/core/github/pull-request-status.ts index 68cb2088785..474bb8742f8 100644 --- a/packages/core/github/pull-request-status.ts +++ b/packages/core/github/pull-request-status.ts @@ -21,9 +21,7 @@ import type { // fields; each rule defaults defensively (`?? 0`, `?? []`, explicit `=== "..."` // checks) so an absent field never fabricates a positive verdict. -// --------------------------------------------------------------------------- // CI status -// --------------------------------------------------------------------------- // Discriminated union for the CI element. `none` is a current snapshot with no // checks; `unavailable` means there is no current snapshot region to render. @@ -94,9 +92,7 @@ export function deriveChecksStatus(input: PullRequestChecksInput): PullRequestCh return input.snapshot_available === true ? { kind: "none" } : { kind: "unavailable" }; } -// --------------------------------------------------------------------------- // Mergeability -// --------------------------------------------------------------------------- // Discriminated union for the mergeability element. `none` renders nothing: // when GitHub has not decided (mergeable unknown/null and no decisive @@ -141,9 +137,7 @@ export function deriveMergeStatus(input: PullRequestMergeInput): PullRequestMerg return { kind: "none" }; } -// --------------------------------------------------------------------------- // Diff stats -// --------------------------------------------------------------------------- export interface PullRequestStatsInput { additions?: number; @@ -163,9 +157,7 @@ export function shouldShowPullRequestStats(input: PullRequestStatsInput): boolea return a + d + f > 0; } -// --------------------------------------------------------------------------- // Row verdict -// --------------------------------------------------------------------------- // The one thing the sidebar row says about a PR: what stands between it and a // merge, or how it ended. `failed` carries the failing check names and whether @@ -224,9 +216,7 @@ export function derivePullRequestVerdict(input: PullRequestVerdictInput): PullRe return { kind: "unknown" }; } -// --------------------------------------------------------------------------- // Row copy -// --------------------------------------------------------------------------- function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); diff --git a/packages/core/hooks/use-file-upload.ts b/packages/core/hooks/use-file-upload.ts index 68e92916df8..603503056ae 100644 --- a/packages/core/hooks/use-file-upload.ts +++ b/packages/core/hooks/use-file-upload.ts @@ -6,49 +6,12 @@ import type { Attachment } from "../types"; import { attachmentDownloadPath } from "../types/attachment-url"; import { MAX_FILE_SIZE } from "../constants/upload"; -// Carries the full Attachment so editors that need preview metadata -// (`content_type`, `download_url`) get it directly. Two URL fields are -// surfaced because they have different lifetimes: -// -// `link` — the same value as `att.url`. Short-lived for the -// LocalStorage backend (HMAC-signed `/uploads/`) -// and a long-lived CDN URL on S3 / CloudFront. This -// is what avatar / logo callers persist into -// `avatar_url` style fields, and what URL-only -// consumers (Markdown renderers without a record -// in hand) get to load directly. Keeping it -// semantically equal to `att.url` preserves the -// pre-MUL-3130 contract for non-markdown callers -// so avatar uploads do not get rerouted through -// the workspace-membership-gated download endpoint. -// -// `markdownLink` — the URL the editor writes into markdown bodies. -// Source: `att.markdown_url` from the server, which -// `buildMarkdownURL` picks per deployment policy -// (public CDN durable URL, or -// `/api/attachments//download`). -// The contract is "absolute, no TTL, loads natively -// on every client" — that's what fixes the Desktop / -// mobile-webview regression where a site-relative -// `/api/attachments//download` couldn't resolve -// against `file://` (MUL-3192). -// -// Falls back through two layers when the server -// didn't populate `markdown_url`: -// 1. `attachmentDownloadPath(att.id)` — the legacy -// site-relative shape. Works on web (Next -// rewrite proxies /api/* to the API host) and -// is what older comments persist; render -// surfaces handle the absolutize for non-web -// clients via attachment.tsx's legacy compat. -// 2. `att.url` — the no-workspace avatar branch -// where there's no attachment-row id at all. -// -// MUL-3130 introduced the persisted-image regression by collapsing -// these two semantics into a single `link` field; MUL-3192 followed up -// by moving the durable-URL choice from the client to the server so -// the persisted shape is correct for the deployment without the client -// having to know whether it's running on web / desktop / mobile. +// UploadResult keeps raw storage and durable Markdown URLs separate. +// link preserves att.url for avatars and other storage-reference consumers. +// markdownLink uses the server's absolute, non-expiring markdown_url so native +// clients can load persisted media across origins. Older responses fall back to +// an attachment-ID path, then the raw URL when no attachment row exists. +// The full Attachment carries preview metadata alongside these conveniences. export type UploadResult = Attachment & { link: string; markdownLink: string; @@ -60,20 +23,8 @@ export interface UploadContext { chatSessionId?: string; } -// pickMarkdownLink chooses the URL the editor will write into markdown. -// -// Order: -// 1. `att.markdown_url` — server-provided durable URL. This is the -// modern contract introduced in MUL-3192; the server (`buildMarkdownURL`) -// decides whether to emit a public CDN URL or an absolute API -// endpoint pinned to `MULTICA_PUBLIC_URL` based on the deployment. -// 2. `attachmentDownloadPath(att.id)` — site-relative legacy shape, -// retained for compatibility with backends old enough to predate -// MUL-3192. Web's Next rewrite makes this load; desktop / mobile -// surfaces hit the attachment.tsx legacy-absolutize fallback. -// 3. `att.url` — no attachment-row id (the no-workspace avatar branch -// of UploadFile). Markdown callers fall back to whatever storage -// backend produced for the upload; persistence is on the caller. +// pickMarkdownLink prefers the server's durable URL. Older responses fall back +// to an attachment-ID path, then raw storage for uploads without a row ID. function pickMarkdownLink(att: Attachment): string { if (att.markdown_url) return att.markdown_url; if (att.id) return attachmentDownloadPath(att.id); diff --git a/packages/core/inbox/mutations.ts b/packages/core/inbox/mutations.ts index 0c6b2880e40..b4becc22f76 100644 --- a/packages/core/inbox/mutations.ts +++ b/packages/core/inbox/mutations.ts @@ -7,35 +7,13 @@ import { useWorkspaceId } from "../hooks"; import type { InboxItem } from "../types"; /** - * Re-read every inbox cache a write can change: both workspace lists and the - * cross-workspace unread summary. + * Refresh workspace inbox lists and the account-level unread summary after a + * confirmed write. Use realtime's refresh helpers so in-flight reads are cancelled + * before refetching; a workspace query prefix does not reach the summary. * - * The unread badge reads that summary (`useInboxUnreadCount`), and it lives - * under its own account-level key which `inboxKeys.all(wsId)` does not reach. - * Every mutation here can change the number it holds, so each one refreshes it - * once the server has confirmed, rather than waiting for the WebSocket echo of - * its own action. - * - * Deliberately the same entry points realtime uses, not local copies: both - * refreshes have to cancel any in-flight request first, and a mutation racing - * a first load hits exactly the same hole a WS event does. A plain - * `invalidateQueries` on the list here would let the list fall behind the - * badge — the two are rendered side by side (see `refreshInboxQuery`). - * - * Not awaited by `onSettled`: the mutation is finished once the server has - * answered, and a background refresh should not hold its lifecycle open. - * - * The rows are patched optimistically but the badge is NOT: it follows the - * server's confirmation, which buys a single writer at the cost of the badge - * trailing the row. Deriving it locally instead — recomputing the count from - * the list cache and writing that back — reads as instant but is unsound: a - * list cache proves only that the list was loaded ONCE, never that it is - * complete or concurrent with the summary, and the account-level summary - * request is not cancelled by the workspace-scoped `cancelQueries` below, so a - * response already in flight lands on top of the local value anyway. Under - * pagination it would be wrong by construction — one loaded page cannot - * produce a global count. If instant feedback is wanted later, it has to be a - * per-group delta that handles the race, not a recomputed total. + * Rows may patch optimistically, but the unread badge follows server confirmation. + * A partial or independently fetched list cannot produce a reliable global total. + * Do not hold the completed mutation open while the refresh runs. */ function refreshInboxAfterWrite(qc: QueryClient, wsId: string) { void onInboxInvalidate(qc, wsId); diff --git a/packages/core/issue-statuses/mutations.ts b/packages/core/issue-statuses/mutations.ts index 55ebdcd3cc0..9905806d855 100644 --- a/packages/core/issue-statuses/mutations.ts +++ b/packages/core/issue-statuses/mutations.ts @@ -11,33 +11,11 @@ import type { } from "../types"; /** - * Catalog mutations (MUL-6243). - * - * No mutation invalidates the catalog on success. The refresh is the - * `issue_status:changed` realtime event, which reaches the writing tab as well - * as every other one, so a catalog edit costs each client exactly ONE catalog - * read. Invalidating here too would make the admin who did the writing the - * only client that reads it twice. (MUL-6458) - * - * That refetch is what CONVERGES the cache, and it is ordered correctly by - * construction: the event is published after the write commits, so the refetch - * it triggers can only read post-commit state. - * - * Which is why no mutation writes its RESPONSE BODY into the cache. A response - * is authoritative for the row as of that write, not as of now — and the two - * channels are independent, so a slow response can land after a refetch that - * already picked up someone else's later write and quietly roll the catalog - * back to a state nothing will correct. Every local write below is instead - * something that stays true no matter what else has landed in between: - * - * - an optimistic field patch (rename, recolor, reorder), - * - an insert of a row nobody else can have seen yet (create), - * - a monotonic flag (archive — there is no unarchive endpoint, and the server - * refuses to edit an archived row, so setting it can never be stale). - * - * On FAILURE the invalidate stays, and it is not symmetry for its own sake: a - * 409 usually means this client's catalog is the stale thing — someone else - * took the name, or archived the row that was being dragged. + * Catalog edits converge through the post-commit issue_status:changed event. + * Avoid a duplicate success invalidation or writing a response snapshot that can + * arrive after a newer refetch. Local patches must remain valid across that race: + * optimistic fields, newly created rows, or monotonic archive flags. + * Invalidate failures because a conflict may reveal stale local catalog state. */ function useCatalogCache() { diff --git a/packages/core/issue-workflows/hooks.test.tsx b/packages/core/issue-workflows/hooks.test.tsx new file mode 100644 index 00000000000..6001a184117 --- /dev/null +++ b/packages/core/issue-workflows/hooks.test.tsx @@ -0,0 +1,126 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { setApiInstance } from "../api"; +import type { ApiClient } from "../api/client"; +import type { IssueWorkflow, Project } from "../types"; +import { useProjectWithWorkflow } from "./hooks"; + +// A consequential write waits for `resolved`. A successful answer that cannot +// say which workflow the issue's project follows reads as "no handoff" exactly +// like a plain project does, while the server still resolves a handler. So +// `resolved` is true only when the answer is authoritative. + +const NAMED = { id: "wf-1", name: "Delivery", is_default: false, steps: [] } as unknown as IssueWorkflow; +const DEFAULT = { id: "wf-default", name: "Default", is_default: true, steps: [] } as unknown as IssueWorkflow; +const project = (id: string, workflow_id: string | null) => ({ id, workflow_id }) as unknown as Project; + +const listIssueWorkflows = vi.fn(); +const listProjects = vi.fn(); +let qc: QueryClient; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function resolveFor(projectId: string | null) { + return renderHook(() => useProjectWithWorkflow("ws-1", projectId), { wrapper }); +} + +beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + setApiInstance({ listIssueWorkflows, listProjects } as unknown as ApiClient); +}); + +afterEach(() => { + qc.clear(); + listIssueWorkflows.mockReset(); + listProjects.mockReset(); + setApiInstance(undefined as never); +}); + +describe("useProjectWithWorkflow resolution", () => { + it("is unresolved when the project list does not contain the issue's project", async () => { + listIssueWorkflows.mockResolvedValue({ workflows: [NAMED, DEFAULT], total: 2 }); + listProjects.mockResolvedValue({ projects: [project("other", null)], total: 1 }); + const { result } = resolveFor("p-1"); + + await waitFor(() => expect(listProjects).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(result.current.resolved).toBe(false); + expect(result.current.workflow).toBeNull(); + }); + + it("is unresolved when the project names a workflow the list does not contain", async () => { + listIssueWorkflows.mockResolvedValue({ workflows: [DEFAULT], total: 1 }); + listProjects.mockResolvedValue({ projects: [project("p-1", "wf-1")], total: 1 }); + const { result } = resolveFor("p-1"); + + await waitFor(() => expect(listProjects).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(result.current.resolved).toBe(false); + }); + + it("is unresolved when a successful workflow list is empty but the project names a workflow", async () => { + listIssueWorkflows.mockResolvedValue({ workflows: [], total: 0 }); + listProjects.mockResolvedValue({ projects: [project("p-1", "wf-1")], total: 1 }); + const { result } = resolveFor("p-1"); + + await waitFor(() => expect(listProjects).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(result.current.resolved).toBe(false); + }); + + it("is unresolved after the workflow list answers with a malformed body", async () => { + // The api client turns a malformed body into a failure, not an empty list. + listIssueWorkflows.mockRejectedValue(new Error("malformed GET /api/issue-workflows response")); + listProjects.mockResolvedValue({ projects: [project("p-1", "wf-1")], total: 1 }); + const { result } = resolveFor("p-1"); + + await waitFor(() => expect(listProjects).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(result.current.resolved).toBe(false); + }); + + it("resolves the named workflow once both lists contain it", async () => { + listIssueWorkflows.mockResolvedValue({ workflows: [NAMED, DEFAULT], total: 2 }); + listProjects.mockResolvedValue({ projects: [project("p-1", "wf-1")], total: 1 }); + const { result } = resolveFor("p-1"); + + await waitFor(() => expect(result.current.resolved).toBe(true)); + expect(result.current.workflow?.id).toBe("wf-1"); + }); + + it("resolves to the Default workflow for a project that names none", async () => { + listIssueWorkflows.mockResolvedValue({ workflows: [NAMED, DEFAULT], total: 2 }); + listProjects.mockResolvedValue({ projects: [project("p-1", null)], total: 1 }); + const { result } = resolveFor("p-1"); + + await waitFor(() => expect(result.current.resolved).toBe(true)); + expect(result.current.workflow?.id).toBe("wf-default"); + }); + + it("resolves to no workflow when none is stored and the project names none", async () => { + listIssueWorkflows.mockResolvedValue({ workflows: [], total: 0 }); + listProjects.mockResolvedValue({ projects: [project("p-1", null)], total: 1 }); + const { result } = resolveFor("p-1"); + + await waitFor(() => expect(result.current.resolved).toBe(true)); + expect(result.current.workflow).toBeNull(); + }); + + it("resolves at once for an issue outside any project", () => { + const { result } = resolveFor(null); + + expect(result.current.resolved).toBe(true); + expect(result.current.workflow).toBeNull(); + }); +}); diff --git a/packages/core/issue-workflows/hooks.ts b/packages/core/issue-workflows/hooks.ts new file mode 100644 index 00000000000..3b6169e6a4b --- /dev/null +++ b/packages/core/issue-workflows/hooks.ts @@ -0,0 +1,76 @@ +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { projectListOptions } from "../projects/queries"; +import type { IssueWorkflow } from "../types"; +import { type IssueWorkflowResolution, issueWorkflowListOptions, resolveIssueWorkflow } from "./queries"; + +const EMPTY: IssueWorkflow[] = []; + +/** + * The workspace's workflows, in name order. Empty until loaded. `isError` is + * true only while there is no data to show, so a failed background refetch + * never replaces a list already on screen. `retry` refetches. + */ +export function useIssueWorkflows(wsId: string): { + workflows: IssueWorkflow[]; + isLoaded: boolean; + isError: boolean; + retry: () => void; +} { + const { data, isError, refetch } = useQuery({ ...issueWorkflowListOptions(wsId), enabled: Boolean(wsId) }); + const retry = useCallback(() => { + void refetch(); + }, [refetch]); + return useMemo( + () => ({ workflows: data ?? EMPTY, isLoaded: data !== undefined, isError: isError && data === undefined, retry }), + [data, isError, retry], + ); +} + +/** + * A project and the workflow it uses. `workflow` is null for the Default + * workflow, and also while either list is still loading or has failed: a board + * must keep rendering. `resolved` tells the two apart (see + * `resolveIssueWorkflow`); a consequential write (a status change that can + * hand the issue off) must wait for it, because a null workflow that is merely + * unresolved looks exactly like "no handoff" and would skip the confirmation. + * `retry` reloads both lists. + */ +export function useProjectWithWorkflow( + wsId: string, + projectId: string | null | undefined, +): IssueWorkflowResolution & { + /** A list is still on its way; an unresolved answer may yet resolve by itself. */ + loading: boolean; + retry: () => void; +} { + const { workflows, isLoaded: workflowsLoaded, isError: workflowsFailed, retry: retryWorkflows } = useIssueWorkflows(wsId); + const { + data: projects, + isPending: projectsPending, + refetch: refetchProjects, + } = useQuery({ + ...projectListOptions(wsId), + enabled: Boolean(wsId) && Boolean(projectId), + }); + const retry = useCallback(() => { + retryWorkflows(); + void refetchProjects(); + }, [retryWorkflows, refetchProjects]); + return useMemo( + () => ({ + ...resolveIssueWorkflow(workflowsLoaded ? workflows : undefined, projects, projectId), + loading: Boolean(projectId) && ((!workflowsLoaded && !workflowsFailed) || (projectsPending && projects === undefined)), + retry, + }), + [projectId, projects, projectsPending, workflows, workflowsLoaded, workflowsFailed, retry], + ); +} + +/** The workflow a project uses, or null for the Default workflow. */ +export function useProjectWorkflow( + wsId: string, + projectId: string | null | undefined, +): IssueWorkflow | null { + return useProjectWithWorkflow(wsId, projectId).workflow; +} diff --git a/packages/core/issue-workflows/index.ts b/packages/core/issue-workflows/index.ts new file mode 100644 index 00000000000..c0867c831fa --- /dev/null +++ b/packages/core/issue-workflows/index.ts @@ -0,0 +1,32 @@ +export { + issueWorkflowKeys, + issueWorkflowListOptions, + resolveProjectWorkflow, + resolveIssueWorkflow, + NO_HANDOFF_BINDING, + type IssueWorkflowResolution, + sharedWorkflowStatusKeys, + defaultWorkflow, + isFixedWorkflowStatus, + isOptionalWorkflowStatus, + statusAcceptsHandoff, + workflowMiddleKeys, + workflowNextStatus, + WORKFLOW_HEAD_STATUSES, + WORKFLOW_TAIL_STATUSES, + WORKFLOW_OPTIONAL_STATUSES, + workflowStep, + stepHandsOff, + workflowAllowsStatus, + projectMoveMayHandOff, + workflowHandoffPreviewOptions, + handoffStepCount, +} from "./queries"; +export { useIssueWorkflows, useProjectWorkflow, useProjectWithWorkflow } from "./hooks"; +export { + useCreateIssueWorkflow, + useUpdateIssueWorkflow, + useDeleteIssueWorkflow, + useSetProjectWorkflow, + useSetWorkflowHandoff, +} from "./mutations"; diff --git a/packages/core/issue-workflows/list-client.test.ts b/packages/core/issue-workflows/list-client.test.ts new file mode 100644 index 00000000000..ca4b1d670b8 --- /dev/null +++ b/packages/core/issue-workflows/list-client.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiClient } from "../api/client"; + +// Callers treat an empty workflow list as the answer "this workspace stores no +// workflow", which lets a status change skip its handoff confirmation. A body +// that is not a workflow list says nothing, so it must fail, not read as empty. + +afterEach(() => vi.unstubAllGlobals()); + +const client = new ApiClient("https://api.example.test"); +const respond = (body: unknown) => + vi.fn().mockImplementation(async () => new Response(JSON.stringify(body), { status: 200 })); + +describe("ApiClient.listIssueWorkflows", () => { + it.each([ + ["a workflows value that is not a list", { workflows: "nope", total: 1 }], + ["a body without a workflows list", {}], + ["a null body", null], + ["a list holding a malformed workflow", { workflows: [{ id: 7 }], total: 1 }], + ])("rejects %s", async (_label, body) => { + vi.stubGlobal("fetch", respond(body)); + + await expect(client.listIssueWorkflows()).rejects.toThrow(); + }); + + it("accepts a genuinely empty list", async () => { + vi.stubGlobal("fetch", respond({ workflows: [], total: 0 })); + + await expect(client.listIssueWorkflows()).resolves.toMatchObject({ workflows: [] }); + }); +}); diff --git a/packages/core/issue-workflows/mutations.ts b/packages/core/issue-workflows/mutations.ts new file mode 100644 index 00000000000..cc66b42a4ad --- /dev/null +++ b/packages/core/issue-workflows/mutations.ts @@ -0,0 +1,117 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { useWorkspaceId } from "../hooks"; +import { issueKeys } from "../issues/queries"; +import { projectKeys } from "../projects/queries"; +import type { + IssueWorkflowWriteRequest, + ListIssueWorkflowsResponse, + SetProjectWorkflowRequest, + SetWorkflowHandoffRequest, +} from "../types"; +import { issueWorkflowKeys } from "./queries"; + +/** + * Workflow mutations (MUL-7420). + * + * Like the status catalog, the list refreshes through the + * `issue_workflow:changed` realtime event; the writer only invalidates on + * failure, when its own copy is the likely stale one. A project switch or a + * step removal can move issues between statuses, and those moves arrive as + * ordinary issue events. + */ + +export function useCreateIssueWorkflow() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (data: IssueWorkflowWriteRequest) => api.createIssueWorkflow(data), + onError: () => { + qc.invalidateQueries({ queryKey: issueWorkflowKeys.all(wsId) }); + }, + }); +} + +export function useUpdateIssueWorkflow() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string } & IssueWorkflowWriteRequest) => + api.updateIssueWorkflow(id, data), + onSuccess: (saved, variables) => { + // The next save names this version. Waiting for the refetch or the + // realtime event would leave a quick second save naming the old one and + // refused as a conflict with the editor's own write. A dry run saved + // nothing. + if (variables.dry_run || !saved?.id) return; + qc.setQueryData(issueWorkflowKeys.list(wsId), (previous) => + previous ? { ...previous, workflows: previous.workflows.map((w) => (w.id === saved.id ? saved : w)) } : previous, + ); + }, + onError: () => { + qc.invalidateQueries({ queryKey: issueWorkflowKeys.all(wsId) }); + }, + }); +} + +export function useDeleteIssueWorkflow() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (id: string) => api.deleteIssueWorkflow(id), + onError: () => { + qc.invalidateQueries({ queryKey: issueWorkflowKeys.all(wsId) }); + }, + }); +} + +export function useSetProjectWorkflow() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ projectId, ...data }: { projectId: string } & SetProjectWorkflowRequest) => + api.setProjectWorkflow(projectId, data), + onSettled: (_data, _error, { projectId }) => { + qc.invalidateQueries({ queryKey: projectKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: projectKeys.detail(wsId, projectId) }); + // project_ids on each workflow row changed. + qc.invalidateQueries({ queryKey: issueWorkflowKeys.all(wsId) }); + // Remapped issues moved columns. + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + }, + }); +} + +/** + * Saves a workflow's handoff for one column, for every project using it — or, + * with `copyForProjectId`, on a copy only that project uses. + */ +export function useSetWorkflowHandoff() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ + workflowId, + statusKey, + copyForProjectId, + ...data + }: { workflowId: string; statusKey: string; copyForProjectId?: string } & SetWorkflowHandoffRequest) => + api.setWorkflowHandoff(workflowId, statusKey, data, copyForProjectId), + onSuccess: (saved, { workflowId, copyForProjectId }) => { + if (!saved.id) return; + if (copyForProjectId) { + // A new workflow row, and the project now points at it. + qc.invalidateQueries({ queryKey: issueWorkflowKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: projectKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: projectKeys.detail(wsId, copyForProjectId) }); + return; + } + qc.setQueryData(issueWorkflowKeys.list(wsId), (old) => + old && { ...old, workflows: old.workflows.map((w) => (w.id === workflowId ? saved : w)) }, + ); + }, + onError: () => { + qc.invalidateQueries({ queryKey: issueWorkflowKeys.all(wsId) }); + }, + }); +} diff --git a/packages/core/issue-workflows/queries.test.ts b/packages/core/issue-workflows/queries.test.ts new file mode 100644 index 00000000000..c94f78eb6eb --- /dev/null +++ b/packages/core/issue-workflows/queries.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import type { IssueWorkflow, IssueWorkflowStep } from "../types"; +import { + projectMoveMayHandOff, + resolveProjectWorkflow, + stepHandsOff, + statusAcceptsHandoff, + workflowAllowsStatus, + workflowMiddleKeys, + workflowNextStatus, + workflowStep, +} from "./queries"; + +const manual = (status_key: string): IssueWorkflowStep => ({ status_key, handler: { type: "none" }, instructions: "" }); + +const columns = (...middle: string[]) => + ["backlog", "todo", ...middle, "blocked", "done", "cancelled"].map(manual); + +const defaultWorkflow: IssueWorkflow = { + id: "wf-default", + workspace_id: "ws-1", + name: "Default", + description: "", + is_default: true, + steps: columns("in_progress", "in_review"), + project_ids: [], + created_at: "", + updated_at: "", +}; + +const content: IssueWorkflow = { + ...defaultWorkflow, + id: "wf-content", + name: "Content", + is_default: false, + steps: columns("drafting", "editing"), +}; + +const lead: IssueWorkflowStep = { status_key: "in_review", handler: { type: "project_lead" }, instructions: "Review it." }; + +describe("project workflow resolution (MUL-7420)", () => { + it("uses the project's workflow, else Default", () => { + expect(resolveProjectWorkflow([defaultWorkflow, content], { workflow_id: "wf-content" })?.id).toBe("wf-content"); + expect(resolveProjectWorkflow([defaultWorkflow, content], { workflow_id: null })?.id).toBe("wf-default"); + // A dangling reference renders as Default, like the server treats it. + expect(resolveProjectWorkflow([defaultWorkflow, content], { workflow_id: "gone" })?.id).toBe("wf-default"); + }); + + it("is unknown while workflows load or without a project", () => { + expect(resolveProjectWorkflow(undefined, { workflow_id: null })).toBeNull(); + expect(resolveProjectWorkflow([defaultWorkflow], undefined)).toBeNull(); + expect(resolveProjectWorkflow([], { workflow_id: null })).toBeNull(); + }); + + it("carries the workflow's own handoffs", () => { + const reviewed = { ...content, steps: content.steps.map((s) => (s.status_key === "editing" ? { ...lead, status_key: "editing" } : s)) }; + const resolved = resolveProjectWorkflow([defaultWorkflow, reviewed], { workflow_id: "wf-content" }); + expect(stepHandsOff(workflowStep(resolved, "editing"))).toBe(true); + expect(workflowStep(resolved, "editing")?.instructions).toBe("Review it."); + expect(stepHandsOff(workflowStep(resolved, "drafting"))).toBe(false); + }); +}); + +describe("workflow helpers (MUL-7420)", () => { + it("allows any status under Default and only listed ones under a named workflow", () => { + expect(workflowAllowsStatus(null, "drafting")).toBe(true); + expect(workflowAllowsStatus(defaultWorkflow, "drafting")).toBe(true); + expect(workflowAllowsStatus(content, "drafting")).toBe(true); + expect(workflowAllowsStatus(content, "in_progress")).toBe(false); + }); + + it("names the middle columns and who may hand off", () => { + expect(workflowMiddleKeys(content)).toEqual(["drafting", "editing"]); + expect(statusAcceptsHandoff("todo")).toBe(true); + expect(statusAcceptsHandoff("blocked")).toBe(true); + expect(statusAcceptsHandoff("backlog")).toBe(false); + expect(statusAcceptsHandoff("done")).toBe(false); + }); + + it("points a step at the next column unless it names its own", () => { + expect(workflowNextStatus(content, "drafting")).toBe("editing"); + // blocked is a detour, not the next step. + expect(workflowNextStatus(content, "editing")).toBe("done"); + const own = { ...content, steps: content.steps.map((s) => (s.status_key === "drafting" ? { ...s, next_status_key: "done" } : s)) }; + expect(workflowNextStatus(own, "drafting")).toBe("done"); + expect(workflowNextStatus(content, "cancelled")).toBe(""); + expect(workflowNextStatus(content, "missing")).toBe(""); + }); +}); + +describe("projectMoveMayHandOff", () => { + const handsOffDrafting: IssueWorkflow = { + ...content, + steps: content.steps.map((s) => (s.status_key === "drafting" ? { ...s, handler: { type: "agent", id: "a-1" } } : s)), + }; + + it("checks the column the issue keeps", () => { + expect(projectMoveMayHandOff(handsOffDrafting, "drafting")).toBe(true); + expect(projectMoveMayHandOff(handsOffDrafting, "todo")).toBe(false); + }); + + it("asks the server when the issue lands on another column", () => { + expect(projectMoveMayHandOff(handsOffDrafting, "in_progress")).toBe(true); + expect(projectMoveMayHandOff(content, "in_progress")).toBe(false); + }); + + it("never asks without a workflow", () => { + expect(projectMoveMayHandOff(null, "todo")).toBe(false); + }); +}); diff --git a/packages/core/issue-workflows/queries.ts b/packages/core/issue-workflows/queries.ts new file mode 100644 index 00000000000..05fa350c6bc --- /dev/null +++ b/packages/core/issue-workflows/queries.ts @@ -0,0 +1,218 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "../api"; +import type { HandoffPreviewBinding, IssueWorkflow, IssueWorkflowStep, Project } from "../types"; + +/** + * Workspace workflows (MUL-7420). + * + * A project's board is its workflow's columns — the named one it picked, or + * the workspace Default — with that workflow's handoffs on them. Helpers + * return null only while workflows are unknown (still loading, or a server + * without workflows), and callers then keep their pre-workflow behavior. + */ + +export const issueWorkflowKeys = { + all: (wsId: string) => ["issue-workflows", wsId] as const, + list: (wsId: string) => [...issueWorkflowKeys.all(wsId), "list"] as const, + handoffPreview: (wsId: string, issueId: string, status: string, projectId = "") => + [...issueWorkflowKeys.all(wsId), "handoff-preview", issueId, status, projectId] as const, +}; + +/** + * What moving an issue to `status` — or into project `projectId` — would do. + * Short-lived: the answer depends on the issue's assignee and runs, which + * change under it. + */ +export function workflowHandoffPreviewOptions(wsId: string, issueId: string, status: string, projectId?: string) { + return queryOptions({ + queryKey: issueWorkflowKeys.handoffPreview(wsId, issueId, status, projectId), + queryFn: () => api.previewWorkflowHandoff(issueId, status, projectId), + staleTime: 0, + gcTime: 30_000, + }); +} + +export function issueWorkflowListOptions(wsId: string) { + return queryOptions({ + queryKey: issueWorkflowKeys.list(wsId), + queryFn: () => api.listIssueWorkflows(), + select: (data) => data.workflows, + // Workflows change only when an admin edits one; the + // `issue_workflow:changed` event refreshes every client when that happens. + staleTime: 5 * 60_000, + }); +} + +/** The workspace Default workflow, once loaded. */ +export function defaultWorkflow(workflows: readonly IssueWorkflow[] | undefined): IssueWorkflow | null { + return workflows?.find((w) => w.is_default) ?? null; +} + +/** Statuses with fixed places at the ends of a workflow, in the order they sit. */ +export const WORKFLOW_HEAD_STATUSES = ["backlog", "todo"] as const; +export const WORKFLOW_TAIL_STATUSES = ["blocked", "done", "cancelled"] as const; +/** Fixed statuses a workflow may leave out; the others every workflow lists. */ +export const WORKFLOW_OPTIONAL_STATUSES = ["backlog", "blocked"] as const; +const FIXED_STATUSES = new Set([...WORKFLOW_HEAD_STATUSES, ...WORKFLOW_TAIL_STATUSES]); +const OPTIONAL_STATUSES = new Set(WORKFLOW_OPTIONAL_STATUSES); +const NO_HANDOFF_STATUSES = new Set(["backlog", "done", "cancelled"]); + +/** Whether a status has a fixed place when a workflow lists it. */ +export function isFixedWorkflowStatus(statusKey: string): boolean { + return FIXED_STATUSES.has(statusKey); +} + +/** Whether a fixed status may be left out of a workflow. */ +export function isOptionalWorkflowStatus(statusKey: string): boolean { + return OPTIONAL_STATUSES.has(statusKey); +} + +/** Whether a column can have a handler: parking and closing columns cannot. */ +export function statusAcceptsHandoff(statusKey: string): boolean { + return !NO_HANDOFF_STATUSES.has(statusKey); +} + +/** The columns between the fixed head and tail, in order. */ +export function workflowMiddleKeys(workflow: Pick): string[] { + return workflow.steps.map((s) => s.status_key).filter((k) => !FIXED_STATUSES.has(k)); +} + +/** + * The column a step's handler is told to move to when done: the step's own + * choice, else the next column (skipping blocked); empty at the end. + */ +export function workflowNextStatus(workflow: Pick, statusKey: string): string { + const index = workflow.steps.findIndex((s) => s.status_key === statusKey); + if (index < 0) return ""; + const own = workflow.steps[index]?.next_status_key; + if (own) return own; + for (const s of workflow.steps.slice(index + 1)) { + if (s.status_key !== "blocked") return s.status_key; + } + return ""; +} + +/** + * The workflow a project uses — its named one, else Default; null while + * workflows are unknown. + */ +export function resolveProjectWorkflow( + workflows: readonly IssueWorkflow[] | undefined, + project: Pick | null | undefined, +): IssueWorkflow | null { + if (!project || !workflows) return null; + const named = project.workflow_id ? workflows.find((w) => w.id === project.workflow_id) : undefined; + return named ?? defaultWorkflow(workflows); +} + +/** + * The binding of a write the client expects to hand nothing off: no handler. + * The server refuses the write, or skips the issue in a batch, when it would + * hand off after all. + */ +export const NO_HANDOFF_BINDING: HandoffPreviewBinding = { + previous_assignee_type: null, + previous_assignee_id: null, + handler_type: null, + handler_id: null, +}; + +/** A project and its workflow, and whether the answer can be trusted. */ +export interface IssueWorkflowResolution { + project: Project | null; + workflow: IssueWorkflow | null; + /** + * False while the lists cannot say which workflow the issue follows: one is + * still loading or failed, the project is missing from the project list, or + * the project names a workflow the workflow list lacks. A null workflow then + * means "unknown", not "hands off to no one"; a consequential write waits. + * True with a null workflow is authoritative: no project, or no workflow is + * stored and the project names none (the built-in Default hands off to no one). + */ + resolved: boolean; +} + +/** + * Resolves the project and workflow of an issue in `projectId` against the two + * lists, with `undefined` meaning "not loaded". The server resolves a workflow + * for any project it knows, so a lookup that comes up empty only counts as + * absence when the lists could have answered it. + */ +export function resolveIssueWorkflow( + workflows: readonly IssueWorkflow[] | undefined, + projects: readonly Project[] | undefined, + projectId: string | null | undefined, +): IssueWorkflowResolution { + if (!projectId) return { project: null, workflow: null, resolved: true }; + const project = projects?.find((p) => p.id === projectId) ?? null; + const namedMissing = !!project?.workflow_id && !workflows?.some((w) => w.id === project.workflow_id); + return { + project, + workflow: resolveProjectWorkflow(workflows, project), + resolved: !!workflows && !!project && !namedMissing, + }; +} + +/** + * The statuses every one of `issues` may take, or null when none is limited. + * Only a named workflow limits an issue's statuses; Default and issues outside + * a project accept the whole catalog, as the server does. A batch status + * change offers this set, so it never shows a status the server would skip. + */ +export function sharedWorkflowStatusKeys( + workflows: readonly IssueWorkflow[] | undefined, + projects: readonly Pick[] | undefined, + issues: readonly { project_id: string | null }[], +): Set | null { + let shared = null as Set | null; + for (const issue of issues) { + const project = issue.project_id ? projects?.find((p) => p.id === issue.project_id) : undefined; + const workflow = resolveProjectWorkflow(workflows, project); + if (!workflow || workflow.is_default) continue; + const keys = new Set(workflow.steps.map((s) => s.status_key)); + shared = shared ? new Set([...shared].filter((key) => keys.has(key))) : keys; + } + return shared; +} + +export function workflowStep( + workflow: IssueWorkflow | null | undefined, + statusKey: string, +): IssueWorkflowStep | undefined { + return workflow?.steps.find((s) => s.status_key === statusKey); +} + +/** Whether entering the step reassigns the issue. */ +export function stepHandsOff(step: IssueWorkflowStep | undefined): boolean { + return !!step && step.handler.type !== "none"; +} + +/** + * Whether a status is allowed for issues of a project using `workflow`. + * Default allows every status, as the server does, so an issue on a status + * Default no longer lists can still be moved off and back. + */ +export function workflowAllowsStatus( + workflow: IssueWorkflow | null | undefined, + statusKey: string, +): boolean { + if (!workflow || workflow.is_default) return true; + return workflow.steps.some((s) => s.status_key === statusKey); +} + +/** + * Whether moving an issue on `statusKey` into a project using `workflow` may + * hand it off, when the move enters the column it lands on: the column it + * keeps hands off, or it lands on another column and some column does. The + * server preview has the final word. + */ +export function projectMoveMayHandOff(workflow: IssueWorkflow | null | undefined, statusKey: string): boolean { + if (!workflow) return false; + if (workflowAllowsStatus(workflow, statusKey)) return stepHandsOff(workflowStep(workflow, statusKey)); + return workflow.steps.some(stepHandsOff); +} + +/** Steps whose entry reassigns the issue. */ +export function handoffStepCount(workflow: IssueWorkflow): number { + return workflow.steps.filter((s) => s.handler.type !== "none").length; +} diff --git a/packages/core/issue-workflows/schemas.test.ts b/packages/core/issue-workflows/schemas.test.ts new file mode 100644 index 00000000000..2a03421c986 --- /dev/null +++ b/packages/core/issue-workflows/schemas.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { parseWithFallback } from "../api/schema"; +import { + EMPTY_ISSUE_WORKFLOW_DRY_RUN, + IssueWorkflowDryRunResponseSchema, + IssueWorkflowSchema, + ListIssueWorkflowsResponseSchema, + WorkflowHandoffPreviewSchema, +} from "../api/schemas"; + +const baseWorkflow = { + id: "wf-1", + workspace_id: "ws-1", + name: "Delivery", + description: "", + is_default: false, + steps: [ + { status_key: "todo", handler: { type: "none" }, instructions: "" }, + { + status_key: "code_review", + handler: { type: "agent", id: "agent-1" }, + instructions: "Review the PR.", + next_status_key: "done", + back_status_key: "todo", + }, + ], + project_ids: ["project-1"], + created_at: "2026-09-24T00:00:00Z", + updated_at: "2026-09-24T00:00:00Z", +}; + +describe("workflow schemas (MUL-7420)", () => { + it("parses a workflow list", () => { + const parsed = ListIssueWorkflowsResponseSchema.parse({ workflows: [baseWorkflow], total: 1 }); + expect(parsed.workflows[0]?.steps[1]?.handler).toEqual({ type: "agent", id: "agent-1" }); + expect(parsed.workflows[0]?.project_ids).toEqual(["project-1"]); + }); + + it("degrades an unknown handler type to a manual step", () => { + const parsed = IssueWorkflowSchema.parse({ + ...baseWorkflow, + steps: [{ status_key: "todo", handler: { type: "robot", id: "x" } }], + }); + expect(parsed.steps[0]?.handler.type).toBe("none"); + expect(parsed.steps[0]?.instructions).toBe(""); + }); + + it("defaults the optional fields an older server may omit", () => { + const { description: _d, project_ids: _p, ...minimal } = baseWorkflow; + const parsed = IssueWorkflowSchema.parse({ ...minimal, steps: [{ status_key: "todo" }] }); + expect(parsed.description).toBe(""); + expect(parsed.project_ids).toEqual([]); + expect(parsed.steps[0]?.handler.type).toBe("none"); + }); + + it("reads a workflow row's status-only steps as manual columns", () => { + const parsed = IssueWorkflowSchema.parse({ ...baseWorkflow, is_default: true, steps: [{ status_key: "todo" }] }); + expect(parsed.is_default).toBe(true); + expect(parsed.steps[0]).toEqual({ status_key: "todo", handler: { type: "none" }, instructions: "" }); + }); + + it("falls back to an empty plan on a malformed dry run", () => { + const parsed = parseWithFallback( + { plan: { required: 5 } }, + IssueWorkflowDryRunResponseSchema, + EMPTY_ISSUE_WORKFLOW_DRY_RUN, + { endpoint: "POST /api/projects/{id}/workflow (dry run)" }, + ); + expect(parsed).toEqual(EMPTY_ISSUE_WORKFLOW_DRY_RUN); + }); + + it("parses a handoff preview and degrades an unknown handler type", () => { + const parsed = WorkflowHandoffPreviewSchema.parse({ + handoff: true, + workflow_name: "Delivery", + from_status: "implement", + to_status: "code_review", + handler_type: "agent", + handler_id: "agent-2", + previous_assignee_type: "agent", + previous_assignee_id: "agent-1", + previous_runs: [{ task_id: "t1", agent_id: "agent-1", status: "running", started_at: "2026-09-24T00:00:00Z" }], + brief: "This project's workflow — Delivery", + }); + expect(parsed.handler_type).toBe("agent"); + expect(parsed.previous_runs).toHaveLength(1); + expect(WorkflowHandoffPreviewSchema.parse({ handoff: true, handler_type: "robot" }).handler_type).toBeNull(); + }); + + it("does not read a malformed handoff preview as no handoff", () => { + // A preview the client cannot read must not become "nothing to confirm". + for (const bad of [{ handoff: "yes", previous_runs: 3 }, {}, null, []]) { + expect(WorkflowHandoffPreviewSchema.safeParse(bad).success).toBe(false); + } + }); +}); diff --git a/packages/core/issues/cache-coordinator.ts b/packages/core/issues/cache-coordinator.ts index 439fae58669..a4d6d44ccf1 100644 --- a/packages/core/issues/cache-coordinator.ts +++ b/packages/core/issues/cache-coordinator.ts @@ -39,46 +39,15 @@ export type IssueFlatCache = InfiniteData; export type IssueTableRowCache = IssueTableRowsResponse; /** - * IssueCacheCoordinator — the one rules table for how a single issue change - * propagates through the query cache. + * IssueCacheCoordinator applies shared mutation and WebSocket cache rules. + * Patch determinate loaded rows, remove rows that leave a filter, and mark keys + * stale when membership or an unloaded row's server-sorted position is uncertain. + * Update bucket totals for known transitions without inserting speculative rows. * - * Every write path converges here: `useUpdateIssue` (onMutate optimistic + - * onSuccess server reconcile), `useBatchUpdateIssues`, and the WS - * `issue:updated` handler all call {@link applyIssueChange}, so "I changed - * it" and "someone else changed it" follow the same rules by construction. - * - * The rules, per loaded bucketed list (workspace board + every myList - * scope — My Issues, Project, actor panels, workspace members/agents tabs): - * - * card present, filter untouched by the change → surgical patch (rebucket - * on status, position-slot insert; never a refetch — refetching the - * visible list is what made drags flicker) - * card present, no longer matches the list's filter → surgical REMOVE - * (bucket total decremented) — the "issue left this surface" case that a - * filter-blind patch used to leave behind (MUL-3669) - * card present, membership undecidable client-side (involves / my:all) → - * patch + mark the key stale - * card absent, change can't affect this list → skip - * card absent, stayed a member + status changed → move one unit of the - * server total between the two buckets immediately, then mark the key - * stale: the row's slot in the destination bucket's loaded window is - * server knowledge, and under staleTime: Infinity an explicit - * invalidation is the only channel that ever fills it in - * card absent, left the list (reassigned / re-projected) → old status - * bucket total -1 - * card absent, may have ENTERED, or anything undecidable (no base entity, - * unknown membership) → mark the key stale (never hard-insert: the - * right page/slot under the list's sort+filter is server knowledge) - * - * Stale keys are NOT invalidated here — timing is the caller's contract: - * mutations defer them to onSettled (invalidating mid-flight would refetch - * uncommitted state and stomp the optimistic patch), the WS path invalidates - * immediately (the server already committed). - * - * The detail cache and the Inbox issue projections are patched in the - * same pass. Aggregate projections that cannot be recomputed from one entity - * (assignee-grouped boards, Gantt, project metrics) go through - * {@link invalidateIssueDerivatives}. + * This helper returns stale keys without invalidating them: mutations wait until + * onSettled so refetches cannot overwrite optimistic work, while committed WS + * changes invalidate immediately. Detail and inbox projections are patched here; + * aggregate projections use invalidateIssueDerivatives. */ export interface IssueCacheChangeResult { diff --git a/packages/core/issues/config/status.ts b/packages/core/issues/config/status.ts index da441bfff53..83cd9b7ddf3 100644 --- a/packages/core/issues/config/status.ts +++ b/packages/core/issues/config/status.ts @@ -63,7 +63,7 @@ export const STATUS_CONFIG: Record< } > = { unstarted: { label: "Unstarted", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" }, - started: { label: "Started", iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", columnBg: "bg-warning/5" }, - done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", columnBg: "bg-info/5" }, + started: { label: "Started", iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", columnBg: "bg-muted/40" }, + done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", columnBg: "bg-muted/40" }, closed: { label: "Closed", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" }, }; diff --git a/packages/core/issues/mutations.test.tsx b/packages/core/issues/mutations.test.tsx index aea920f5d29..babad7f0b2e 100644 --- a/packages/core/issues/mutations.test.tsx +++ b/packages/core/issues/mutations.test.tsx @@ -734,6 +734,27 @@ describe("useBatchUpdateIssues — optimistic patch covers filtered boards too", }); }); + it("rolls back and refetches when the server skips some issues", async () => { + batchUpdateIssues.mockResolvedValue({ updated: 0 }); + const invalidateSpy = vi.spyOn(qc, "invalidateQueries"); + + const { result } = renderHook(() => useBatchUpdateIssues(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync({ ids: ["issue-1"], updates: { status: "in_progress" } }); + }); + + for (const key of [wsKey, myKey]) { + expect(bucketIds(key, "unstarted")).toEqual(["issue-1"]); + expect(bucketIds(key, "started")).toEqual([]); + } + const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey); + expect(invalidatedKeys).toContainEqual(issueKeys.list(WS_ID)); + expect(invalidatedKeys).toContainEqual(issueKeys.detail(WS_ID, "issue-1")); + }); + it("rolls both caches back when the request fails", async () => { batchUpdateIssues.mockRejectedValue(new Error("boom")); diff --git a/packages/core/issues/mutations.ts b/packages/core/issues/mutations.ts index 5dfc3cc81d2..5f4562dc426 100644 --- a/packages/core/issues/mutations.ts +++ b/packages/core/issues/mutations.ts @@ -56,10 +56,8 @@ import { reconcileIssueFullSnapshotRevision, } from "./ws-updaters"; -// --------------------------------------------------------------------------- // Shared mutation variable types — used by both mutation hooks and // useMutationState consumers to keep the type assertion in sync. -// --------------------------------------------------------------------------- export type ToggleCommentReactionVars = { commentId: string; @@ -81,13 +79,9 @@ export type UpdateIssueMutationInput = { move_intent?: Pick; } & UpdateIssueRequest; -// --------------------------------------------------------------------------- // Per-status pagination -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- // Issue CRUD -// --------------------------------------------------------------------------- // Shared by the single and batch update hooks, so the inbox re-read can wait // for the last of them (see `owedInboxRereads`). @@ -206,6 +200,9 @@ export function useUpdateIssue() { // a rapid follow-up edit. mutationFn still sends the full payload. const { suppress_run: _suppressRun, + stop_previous_assignee_runs: _stopPreviousAssigneeRuns, + handoff_preview: _handoffPreview, + enter_workflow_step: _enterWorkflowStep, duplicate_of_issue_id: _duplicateOfIssueId, description: _description, description_base: _descriptionBase, @@ -318,6 +315,9 @@ export function useUpdateIssue() { // is the plain surgical patch it always was. const { suppress_run: _suppressRun, + stop_previous_assignee_runs: _stopPreviousAssigneeRuns, + handoff_preview: _handoffPreview, + enter_workflow_step: _enterWorkflowStep, duplicate_of_issue_id: _duplicateOfIssueId, description_base: _descriptionBase, move_intent: _moveIntent, @@ -503,9 +503,30 @@ export function useDeleteIssue() { }); } +interface BatchUpdateSnapshots { + prevLists?: Iterable; + prevFlatLists?: Iterable; + prevTableRows?: Iterable; + prevDetailById?: Iterable; + prevInboxList?: unknown; + prevArchivedInboxCaches?: Iterable; + prevChildren?: Iterable; +} + export function useBatchUpdateIssues() { const qc = useQueryClient(); const wsId = useWorkspaceId(); + const rollback = (ctx: BatchUpdateSnapshots | undefined) => { + for (const [key, snapshot] of ctx?.prevLists ?? []) qc.setQueryData(key, snapshot); + for (const [key, snapshot] of ctx?.prevFlatLists ?? []) qc.setQueryData(key, snapshot); + for (const [key, snapshot] of ctx?.prevTableRows ?? []) qc.setQueryData(key, snapshot); + for (const [id, snapshot] of ctx?.prevDetailById ?? []) qc.setQueryData(issueKeys.detail(wsId, id), snapshot); + if (ctx?.prevInboxList !== undefined) qc.setQueryData(inboxKeys.list(wsId), ctx.prevInboxList); + for (const [key, snapshot] of ctx?.prevArchivedInboxCaches ?? []) qc.setQueryData(key, snapshot); + for (const [parentId, snapshot] of ctx?.prevChildren ?? []) { + qc.setQueryData(issueKeys.children(wsId, parentId), snapshot); + } + }; return useMutation({ mutationKey: issueWriteMutationKey(wsId), mutationFn: ({ @@ -522,6 +543,9 @@ export function useBatchUpdateIssues() { // until a refetch returns the committed result. const { suppress_run: _suppressRun, + stop_previous_assignee_runs: _stopPreviousAssigneeRuns, + handoff_preview: _handoffPreview, + enter_workflow_step: _enterWorkflowStep, description: _description, description_base: _descriptionBase, ...patch @@ -618,38 +642,18 @@ export function useBatchUpdateIssues() { affectedParentIds, }; }, - onError: (_err, _vars, ctx) => { - if (ctx?.prevLists) { - for (const [key, snapshot] of ctx.prevLists) { - qc.setQueryData(key, snapshot); - } - } - if (ctx?.prevFlatLists) { - for (const [key, snapshot] of ctx.prevFlatLists) { - qc.setQueryData(key, snapshot); - } - } - if (ctx?.prevTableRows) { - for (const [key, snapshot] of ctx.prevTableRows) { - qc.setQueryData(key, snapshot); - } - } - if (ctx?.prevDetailById) { - for (const [id, snapshot] of ctx.prevDetailById) { - qc.setQueryData(issueKeys.detail(wsId, id), snapshot); - } - } - if (ctx?.prevInboxList !== undefined) { - qc.setQueryData(inboxKeys.list(wsId), ctx.prevInboxList); - } - for (const [key, snapshot] of ctx?.prevArchivedInboxCaches ?? []) { - qc.setQueryData(key, snapshot); - } - if (ctx?.prevChildren) { - for (const [parentId, snapshot] of ctx.prevChildren) { - qc.setQueryData(issueKeys.children(wsId, parentId), snapshot); - } - } + onError: (_err, _vars, ctx) => rollback(ctx), + // The server skips items its per-issue guards refuse (a status the + // issue's workflow does not list, an assignee it cannot take) and reports + // only how many it wrote. Which ones were skipped is unknown, so the + // optimistic writes are undone and the affected caches refetched. + onSuccess: (data, { ids }, ctx) => { + if (data.updated >= ids.length) return; + rollback(ctx); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.flatAll(wsId) }); + for (const id of ids) qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, id) }); }, onSettled: (_data, err, _vars, ctx) => { settleInboxAfterIssueWrite(qc, wsId, ctx?.inboxWrite, err !== null); @@ -816,9 +820,7 @@ export function useBatchDeleteIssues() { }); } -// --------------------------------------------------------------------------- // Comments / Timeline -// --------------------------------------------------------------------------- type TimelineCache = TimelineEntry[]; @@ -1091,9 +1093,7 @@ export function useToggleCommentReaction(issueId: string) { }); } -// --------------------------------------------------------------------------- // Issue-level Reactions -// --------------------------------------------------------------------------- export function useToggleIssueReaction(issueId: string) { const qc = useQueryClient(); @@ -1119,9 +1119,7 @@ export function useToggleIssueReaction(issueId: string) { }); } -// --------------------------------------------------------------------------- // Issue Subscribers -// --------------------------------------------------------------------------- export function useToggleIssueSubscriber(issueId: string) { const qc = useQueryClient(); diff --git a/packages/core/modals/store.ts b/packages/core/modals/store.ts index 3c9664438b7..5711839f0d5 100644 --- a/packages/core/modals/store.ts +++ b/packages/core/modals/store.ts @@ -13,6 +13,7 @@ type ModalType = | "issue-add-child" | "issue-delete-confirm" | "issue-run-confirm" + | "issue-workflow-handoff" | null; export type IssueLimitRecoveryReason = "issue_limit" | "autopilot_quota"; diff --git a/packages/core/onboarding/index.ts b/packages/core/onboarding/index.ts index 738bc9d73d9..c732b8d05f0 100644 --- a/packages/core/onboarding/index.ts +++ b/packages/core/onboarding/index.ts @@ -25,10 +25,6 @@ export { type BootstrapMikaResult, type MikaOnboardingLanguage, } from "./use-bootstrap-mika"; -export { - useWelcomeStore, - type WelcomeSignal, -} from "./welcome-store"; export { MIKA_SYSTEM_KEY, isMikaAgent, diff --git a/packages/core/onboarding/needs-backfill.test.ts b/packages/core/onboarding/needs-backfill.test.ts index e2fc00ed0b0..25a24010808 100644 --- a/packages/core/onboarding/needs-backfill.test.ts +++ b/packages/core/onboarding/needs-backfill.test.ts @@ -12,7 +12,6 @@ const BASE_USER: User = { avatar_url: null, onboarded_at: "2025-01-01T00:00:00Z", onboarding_questionnaire: {}, - starter_content_state: "imported", language: null, profile_description: "", timezone: null, diff --git a/packages/core/onboarding/step-order.ts b/packages/core/onboarding/step-order.ts index ad4423d5d77..ece7828d9c3 100644 --- a/packages/core/onboarding/step-order.ts +++ b/packages/core/onboarding/step-order.ts @@ -1,32 +1,10 @@ import type { OnboardingStep } from "./types"; /** - * Canonical order of the persisted onboarding steps. - * - * Single source of truth for "what step comes after what" — consumed - * by the UI progress indicator to compute `index of current_step` and - * `total step count`. Inserting, reordering, or removing a step only - * requires changing this array; every call site that reads it updates - * automatically. - * - * Intentionally excludes "welcome": welcome is a first-entry product - * intro, not a persisted step. It doesn't show a progress indicator - * for the same reason — users shouldn't think of reading the intro - * as progress toward completing setup. - * - * Two questions are intentionally NOT steps anymore: - * - * - "source" (How did you hear about Multica?) is pure attribution - * data with zero user-facing payoff, so it no longer taxes the - * critical path. It is collected post-onboarding by the workspace - * source-backfill prompt, and only after agents have completed - * work for the user — see `needs-backfill.ts`. - * - "role" / "use_case" merged into the single "about_you" step so - * related personalization inputs stay together. - * - * Runtime is the final form step. A connected path provisions Mika and opens - * the interactive onboarding chat as part of the runtime step's submit action; - * that chat is the product experience itself, not another progress-screen step. + * Canonical persisted onboarding order for progress and navigation. + * Welcome is an unpersisted introduction; source attribution is collected later. + * About-you combines personalization questions. Runtime is the final form step; + * its connected path provisions Mika and enters chat without another progress step. */ export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [ "about_you", diff --git a/packages/core/onboarding/types.ts b/packages/core/onboarding/types.ts index 46b66b7d911..91b70604a77 100644 --- a/packages/core/onboarding/types.ts +++ b/packages/core/onboarding/types.ts @@ -55,33 +55,13 @@ export type UseCase = | "other"; /** - * Questionnaire shape. `use_case` allows multiple values (users hire - * Multica for several jobs at once); `source` and `role` are single- - * select — for `source` we capture the primary acquisition channel - * for clean self-reported-attribution math (the array shape is - * preserved for back-compat with v2 multi-select rows; the client - * now always commits a one-element array), and `role` stays single - * because downstream personalization wants a primary identity. + * Questionnaire persistence shared by onboarding and source backfill. + * use_case is multi-select; source and role are single-select. Source retains an + * array shape for stored compatibility and is collected after agent work completes. + * Explicit *_skipped flags distinguish declines from unanswered fields. * - * `role` / `use_case` are collected in-flow on the About-you step; - * `source` is no longer asked during onboarding — it is collected - * after the user has seen agents complete work, via the workspace - * source-backfill prompt (see `needs-backfill.ts`). The slots stay in - * this one shape because they share the same JSONB column and PATCH - * endpoint. - * - * `*_skipped: true` distinguishes an explicit Skip / decline from a - * slot the user never reached. Both states are "unknown" for - * personalization purposes; the skip marker exists for analytics and - * so future re-prompts can avoid nagging users who already declined. - * - * Backward compat: prior versions of this app wrote `source` and - * `use_case` as a single string. `mergeQuestionnaire` in - * `onboarding-flow.tsx` upgrades those rows to single-element arrays - * on read; the server's `questionnaireAnswers.UnmarshalJSON` does the - * same. `version` stays at 2 — the JSONB column is schema-less so a - * mechanical bump would only show up in analytics, not in storage, - * and we keep one funnel cohort. + * Readers normalize older single-string source/use_case values to arrays. + * Version 2 remains the shared analytics cohort; the JSONB storage is schema-less. */ export interface QuestionnaireAnswers { source: Source[]; diff --git a/packages/core/onboarding/welcome-store.test.ts b/packages/core/onboarding/welcome-store.test.ts deleted file mode 100644 index f0650e5bb46..00000000000 --- a/packages/core/onboarding/welcome-store.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { useWelcomeStore, type WelcomeSignal } from "./welcome-store"; - -const sample: WelcomeSignal = { - workspaceId: "ws-1", - choice: "skip", -}; - -describe("welcome-store", () => { - beforeEach(() => { - useWelcomeStore.getState().reset(); - }); - - it("set() stores the signal and clears any prior dismissed flag", () => { - useWelcomeStore.getState().dismiss(); - expect(useWelcomeStore.getState().dismissed).toBe(true); - useWelcomeStore.getState().set(sample); - expect(useWelcomeStore.getState().signal).toEqual(sample); - // A fresh set() means a fresh Welcome session — dismissed resets so - // the next mount renders the new signal. - expect(useWelcomeStore.getState().dismissed).toBe(false); - }); - - it("dismiss() marks dismissed without clearing the signal", () => { - useWelcomeStore.getState().set(sample); - useWelcomeStore.getState().dismiss(); - expect(useWelcomeStore.getState().signal).toEqual(sample); - expect(useWelcomeStore.getState().dismissed).toBe(true); - }); - - it("reset() clears signal AND dismissed (used on logout)", () => { - useWelcomeStore.getState().set(sample); - useWelcomeStore.getState().dismiss(); - useWelcomeStore.getState().reset(); - expect(useWelcomeStore.getState().signal).toBeNull(); - expect(useWelcomeStore.getState().dismissed).toBe(false); - }); -}); diff --git a/packages/core/onboarding/welcome-store.ts b/packages/core/onboarding/welcome-store.ts deleted file mode 100644 index 7f3b2a1426f..00000000000 --- a/packages/core/onboarding/welcome-store.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { create } from "zustand"; - -/** - * Welcome signal — written by the runtime step only when the user skips - * connecting a runtime, then consumed exactly once by the - * `` component in the workspace shell. - * - * The signal is the bridge between the onboarding screen and the workspace - * shell that succeeds it. `onboarded_at` is already true by the time we - * land in the workspace — this carries the transient no-runtime intent - * without persisting it on the user - * row (the v2 design persisted it as fields and leaked complexity - * everywhere). - * - * The signal is only needed when the user explicitly skips runtime setup. - * The workspace welcome hook seeds one install-runtime guide and shows it in - * a modal. Once a runtime appears, the Runtimes page offers the same Mika - * bootstrap used by the connected onboarding path. Runtime-connected - * onboarding creates Mika before reaching the workspace and does not use - * this signal. - */ -export interface WelcomeSignal { - workspaceId: string; - choice: "skip"; -} - -interface WelcomeStoreState { - signal: WelcomeSignal | null; - /** - * True after the user has explicitly engaged with the Welcome surface — - * opened the runtime guide or closed the Skip modal. - * Components MUST treat `signal && !dismissed` as the render condition; - * `dismiss()` is what removes the modal from view. - */ - dismissed: boolean; - set: (signal: WelcomeSignal) => void; - /** - * Mark the Welcome surface as engaged-with. Idempotent. Keeps the - * signal in the store as a record but components will stop rendering. - * We don't fully clear the store on dismiss so a debug peek (or a - * future analytics hook) can still see which path the user took. - */ - dismiss: () => void; - /** - * Full reset — clear signal AND dismissed. Used by tests and by the - * user logout flow (so a different user logging into the same browser - * doesn't inherit a stale signal). - */ - reset: () => void; -} - -/** - * Zustand store, deliberately NOT persisted. Reloading the page drops the - * signal — that's the correct behavior: the user already finished - * onboarding (onboarded_at is set), and Welcome was a one-shot UI. We - * never want a refresh to re-trigger setup-guide creation. - * - * Why subscription model instead of read-once-consume: React 18+ StrictMode - * dev mode mounts components twice (mount → unmount → mount). If the - * component consumed the store in a `useState` initializer, the second - * mount would see an already-empty store and render null — Welcome would - * never appear in dev. Subscribing to a stable store + tracking dismissal - * in the store sidesteps both mounts seeing the same state. - */ -export const useWelcomeStore = create((set) => ({ - signal: null, - dismissed: false, - set: (signal) => set({ signal, dismissed: false }), - dismiss: () => set({ dismissed: true }), - reset: () => set({ signal: null, dismissed: false }), -})); diff --git a/packages/core/package.json b/packages/core/package.json index 1a2100ed30e..458fff02d6a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,6 +36,10 @@ "./issue-statuses/queries": "./issue-statuses/queries.ts", "./issue-statuses/hooks": "./issue-statuses/hooks.ts", "./issue-statuses/mutations": "./issue-statuses/mutations.ts", + "./issue-workflows": "./issue-workflows/index.ts", + "./issue-workflows/queries": "./issue-workflows/queries.ts", + "./issue-workflows/hooks": "./issue-workflows/hooks.ts", + "./issue-workflows/mutations": "./issue-workflows/mutations.ts", "./issues": "./issues/index.ts", "./issues/batch": "./issues/batch.ts", "./issues/canonical-id": "./issues/canonical-id.ts", diff --git a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx index aeaea6c8a6e..334b9122f6a 100644 --- a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx +++ b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx @@ -13,6 +13,7 @@ import { runtimeKeys } from "../runtimes/queries"; import { workspaceWorkingAgentsKeys } from "../agents/queries"; import { workspaceKeys } from "../workspace/queries"; import { issueStatusKeys } from "../issue-statuses/queries"; +import { issueWorkflowKeys } from "../issue-workflows/queries"; import { markWorkspaceDeletePending, unmarkWorkspaceDeletePending, @@ -126,15 +127,15 @@ describe("useRealtimeSync — ws instance change", () => { rerender({ ws: ws2 }); // Should have called invalidateQueries for all workspace-scoped keys - // (16 workspace-scoped [incl. property definitions] + 6 per-issue - // prefixes + the workspace working-agents projection + 5 per-chat - // prefixes + 1 workspaceKeys.list() + 1 cross-workspace inbox unread - // summary = 31 calls). + // (17 workspace-scoped [incl. property definitions and workflows] + 6 + // per-issue prefixes + the workspace working-agents projection + 5 + // per-chat prefixes + 1 workspaceKeys.list() + 1 cross-workspace inbox + // unread summary = 32 calls). // // Awaited rather than counted synchronously: the inbox unread summary // refresh cancels any in-flight request before invalidating (see // onInboxSummaryInvalidate), so that one lands after the synchronous ones. - await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(31)); + await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(32)); }); it("does not re-invalidate when rerendered with the same ws instance", () => { @@ -173,6 +174,24 @@ describe("useRealtimeSync — ws instance change", () => { expect(calls).toContainEqual(issueStatusKeys.all("ws-1")); }); + it.each(["project:created", "project:updated", "project:deleted"])("refreshes workflow usage after %s", (type) => { + vi.useFakeTimers(); + try { + const ws = createMockWs(); + renderHook(() => useRealtimeSync(ws, stores), { wrapper: createWrapper(qc) }); + const onAny = vi.mocked(ws.onAny).mock.calls[0]![0]; + invalidateSpy.mockClear(); + + onAny({ type, payload: {} } as never); + vi.advanceTimersByTime(100); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: issueWorkflowKeys.all("ws-1") }); + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: issueWorkflowKeys.all("ws-2") }); + } finally { + vi.useRealTimers(); + } + }); + it("invalidates agent projections when a daemon changes liveness", () => { vi.useFakeTimers(); try { diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 4aac257a5c8..54144b09ab1 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -18,6 +18,7 @@ import { runtimeKeys } from "../runtimes/queries"; import { labelKeys } from "../labels/queries"; import { propertyKeys } from "../properties/queries"; import { issueStatusKeys } from "../issue-statuses/queries"; +import { issueWorkflowKeys } from "../issue-workflows/queries"; import { agentTaskSnapshotKeys, workspaceWorkingAgentsKeys, @@ -674,6 +675,7 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void { // 5-minute staleTime — long enough to offer a status the server already // archived, or to keep painting its old name. qc.invalidateQueries({ queryKey: issueStatusKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueWorkflowKeys.all(wsId) }); } // Cross-workspace, so outside the wsId guard: a reconnect may have missed // inbox events from any workspace, so re-pull the switcher-dot summary. @@ -833,6 +835,9 @@ export function useRealtimeSync( const wsId = getCurrentWsId(); if (wsId) { qc.invalidateQueries({ queryKey: projectKeys.all(wsId) }); + // Workflow responses include the projects using them. Creating, + // deleting or changing a project also changes that usage list. + qc.invalidateQueries({ queryKey: issueWorkflowKeys.all(wsId) }); // The issue table can filter on a project's status, so a // project create/update/delete changes which issues a filtered // window holds. The payload carries no previous status to compare @@ -888,6 +893,12 @@ export function useRealtimeSync( }); } }, + // Workspace workflows (MUL-7420). Same contract as the status catalog: + // the event carries no row, so every write answers with one list read. + issue_workflow: () => { + const wsId = getCurrentWsId(); + if (wsId) qc.invalidateQueries({ queryKey: issueWorkflowKeys.all(wsId) }); + }, pin: () => { const wsId = getCurrentWsId(); const userId = authStore.getState().user?.id; diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index ff3a3b06854..8d1759d8016 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -7,7 +7,6 @@ export type AgentRuntimeMode = "local" | "cloud"; export type AgentVisibility = "workspace" | "private"; -// --------------------------------------------------------------------------- // Agent invocation permissions (MUL-3963) // // `permission_mode` + `invocation_targets` are the AUTHORITATIVE gate for who @@ -24,7 +23,6 @@ export type AgentVisibility = "workspace" | "private"; // - permission_mode "public_to" + workspace target: any workspace member // - permission_mode "public_to" + member target: only the matching user // - team target: reserved, INERT in v1 (never grants) -// --------------------------------------------------------------------------- export type AgentPermissionMode = "private" | "public_to"; @@ -93,7 +91,6 @@ export interface RuntimeDevice { export type AgentRuntime = RuntimeDevice; -// --------------------------------------------------------------------------- // Custom runtime profiles (MUL-3284) // // A RuntimeProfile is a workspace-level *definition* of a custom runtime @@ -101,7 +98,6 @@ export type AgentRuntime = RuntimeDevice; // *instance*. An admin authors a profile (display name + base protocol // family + the CLI command to launch), and daemons can then register // runtimes against it; those instances carry `profile_id` pointing back here. -// --------------------------------------------------------------------------- // The fixed allow-list of base protocol families a custom runtime can wrap. // These are the only backends the create flow may select; the server rejects @@ -730,6 +726,8 @@ export interface StoredAgentDraft { member_ids: string[]; team_ids: string[]; applied_message_id: string | null; + /** Recovery notice acknowledged by manual editing or dismissal. */ + dismissed_message_id?: string | null; } /** One unfinished agent-creation conversation, as listed by the studio. */ diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 50b07951bd7..1e78a4da097 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -80,6 +80,22 @@ export interface UpdateIssueRequest { * MUL-3375). The assignee/status change still applies. Control field — * strip from optimistic cache patches; never written onto the Issue. */ suppress_run?: boolean; + /** When a status change hands the issue off to a workflow step's handler, + * also stop the active runs of the agent it was taken from (MUL-7420). + * Control field — strip from optimistic cache patches. */ + stop_previous_assignee_runs?: boolean; + /** What the handoff confirmation showed: the issue's assignee and the handler + * the workflow resolved. The server answers 409 `handoff_preview_stale` when + * the write would hand off and either no longer matches. A preview that + * found no handoff is sent with a null handler, which refuses a handler that + * became resolvable since. Control field — strip from optimistic cache + * patches. */ + handoff_preview?: HandoffPreviewBinding; + /** With a project move: enter the destination workflow's column the issue + * lands on, handing it off like a status change (MUL-7420). Without it a + * project move is administrative. Control field — strip from optimistic + * cache patches. */ + enter_workflow_step?: boolean; /** Marks this issue as a duplicate of another issue (MUL-7349). The server * also sets status to cancelled; any later status change away from * cancelled removes the mark. Write-only — read it back through @@ -100,6 +116,24 @@ export interface IssueDuplicates { * optimistic animation, but the canonical position is derived from these * workspace-scoped neighbors by `POST /api/issues/:id/move`. */ +/** The assignee and handler a handoff confirmation was shown for. */ +export interface HandoffPreviewBinding { + previous_assignee_type: string | null; + previous_assignee_id: string | null; + handler_type: string | null; + handler_id: string | null; +} + +/** + * What a batch update wrote. `updated_ids` names the issues it wrote; the server + * skips an issue its per-issue guards refuse (a handoff the client did not + * preview, a status its workflow omits). Absent from a server that predates it. + */ +export interface BatchUpdateIssuesResponse { + updated: number; + updated_ids?: string[]; +} + export interface MoveIssueRequest extends Pick< UpdateIssueRequest, @@ -108,6 +142,8 @@ export interface MoveIssueRequest | "assignee_id" | "parent_issue_id" | "project_id" + | "stop_previous_assignee_runs" + | "handoff_preview" > { before_id: string | null; after_id: string | null; diff --git a/packages/core/types/attachment-url.ts b/packages/core/types/attachment-url.ts index 9f73af88546..9b9a980594d 100644 --- a/packages/core/types/attachment-url.ts +++ b/packages/core/types/attachment-url.ts @@ -1,24 +1,7 @@ /** - * Stable URL helpers for attachment references that get persisted into - * markdown bodies (issue descriptions, comment bodies, chat messages). - * - * Background — MUL-3130: - * - * The original upload flow returned `att.url` as the link to embed in - * markdown. After PR #3903 (MUL-3132), `att.url` for the LocalStorage - * backend became a 30-minute HMAC-signed `/uploads/?exp&sig` URL. - * That URL is short-lived by design — it's the in-memory token that - * keeps native browser /