Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
aa5dbd2681 fix(onboarding): pin sync, welcome layout, runtime bootstrap state
Follow-ups on the onboarding flow shipped in #1411.

Pin state synchronization:
- ImportStarterContent now publishes pin:created after commit so the
  sidebar refreshes without a hard reload (previously the pins landed
  in the DB but no event was fired).
- ReorderPins publishes pin:reordered, keeping order in sync across
  web + desktop sessions.
- StarterContentPrompt.onImport invalidates queries locally, mirroring
  the useCreatePin / useDeletePin / useReorderPins onSettled pattern,
  so the originating session's refresh doesn't depend on the WS
  round-trip (WS is the signal for OTHER sessions).
- ImportStarterContent rejects malformed workspace_id up front with
  400 instead of falling through to a misleading 403.

Welcome step layout:
- Switch the two-column hero from CSS Grid to a flex row. Both
  columns share the container's full height via items-stretch +
  justify-center, so the bg-muted/40 backdrop fills edge-to-edge on
  tall viewports and left/right content stays vertically centred.

Desktop runtime bootstrap state:
- New DesktopRuntimesPage wrapper subscribes to window.daemonAPI and
  forwards a `bootstrapping` prop to RuntimeList. While the bundled
  daemon is booting, the empty state renders "Starting local
  runtime…" instead of the misleading "Run multica daemon start"
  hint. Web leaves the prop undefined — behaviour unchanged.

Small polish:
- CLI install dialog caps at 85vh with an internal scroll so the
  Connect button stays reachable when multiple runtimes are
  registered.
- Drop the env-aware CLI setup command; onboarding always targets
  cloud, so `multica setup` is enough — no need to thread apiUrl /
  appUrl through the dialog.

Developer tooling:
- pnpm dev:desktop:staging — parallel dev command that loads
  .env.staging (copilothub backend) via `electron-vite --mode
  staging`, so switching between local and staging no longer
  requires hand-editing env files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:44:15 +08:00
17 changed files with 221 additions and 134 deletions

View File

@@ -19,6 +19,7 @@
"bundle-cli": "node scripts/bundle-cli.mjs",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",

View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { RuntimesPage } from "@multica/views/runtimes";
import { DaemonRuntimeCard } from "./daemon-runtime-card";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
* `daemonAPI` (main-process daemon state) into the page so its empty
* state can distinguish "no runtime registered" from "runtime is on its
* way" — without the bundled daemon's status, the page shows a
* misleading "Run multica daemon start" hint during the few seconds
* between page load and the daemon's first registration.
*
* `bootstrapping` is true while the daemon is installing, starting, or
* already running but hasn't surfaced as a server-side runtime yet.
* RuntimeList only shows the spinner when the runtime list is also
* empty, so once the daemon registers (and the list fills) the flag
* has no visible effect.
*/
export function DesktopRuntimesPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
useEffect(() => {
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const bootstrapping =
status.state === "installing_cli" ||
status.state === "starting" ||
status.state === "running";
return (
<RuntimesPage
topSlot={<DaemonRuntimeCard />}
bootstrapping={bootstrapping}
/>
);
}

View File

@@ -13,9 +13,8 @@ import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
@@ -114,7 +113,7 @@ export const appRoutes: RouteObject[] = [
},
{
path: "runtimes",
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
element: <DesktopRuntimesPage />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
@@ -21,11 +21,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
* otherwise fall back to root (proxy / landing picks the user's first ws
* or bounces to onboarding if still zero).
*
* The CLI install card is wired here so its `multica setup` command
* points at THIS server — dev landing on localhost gets a localhost
* self-host command, prod cloud gets the plain `multica setup`, prod
* self-host gets one with explicit URLs. `appUrl` lives in useState
* so SSR doesn't error on `window` — it fills in on mount.
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
* slot so the flow can render it inside the CLI dialog. The commands it
* shows are hardcoded — nothing environmental to thread through.
*/
export default function OnboardingPage() {
const router = useRouter();
@@ -36,11 +34,6 @@ export default function OnboardingPage() {
...workspaceListOptions(),
enabled: !!user && hasOnboarded,
});
const [appUrl, setAppUrl] = useState<string | undefined>(undefined);
useEffect(() => {
setAppUrl(window.location.origin);
}, []);
useEffect(() => {
if (isLoading || !user) {
@@ -72,12 +65,7 @@ export default function OnboardingPage() {
router.push(paths.root());
}
}}
runtimeInstructions={
<CliInstallInstructions
apiUrl={process.env.NEXT_PUBLIC_API_URL}
appUrl={appUrl}
/>
}
runtimeInstructions={<CliInstallInstructions />}
/>
</div>
);

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev:web": "turbo dev --filter=@multica/web",
"dev:desktop": "turbo dev --filter=@multica/desktop",
"dev:desktop:staging": "turbo dev:staging --filter=@multica/desktop",
"build": "turbo build",
"typecheck": "turbo typecheck",
"test": "turbo test",

View File

@@ -54,6 +54,7 @@ export type WSEventType =
| "project:deleted"
| "pin:created"
| "pin:deleted"
| "pin:reordered"
| "invitation:created"
| "invitation:accepted"
| "invitation:declined"

View File

@@ -3,11 +3,15 @@
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useNavigation } from "@multica/views/navigation";
import { useCurrentWorkspace, paths } from "@multica/core/paths";
import type { QuestionnaireAnswers } from "@multica/core/onboarding";
import { pinKeys } from "@multica/core/pins";
import { projectKeys } from "@multica/core/projects";
import { issueKeys } from "@multica/core/issues/queries";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
@@ -39,6 +43,7 @@ export function StarterContentPrompt() {
const user = useAuthStore((s) => s.user);
const refreshMe = useAuthStore((s) => s.refreshMe);
const { push } = useNavigation();
const qc = useQueryClient();
const [submitting, setSubmitting] = useState<"import" | "dismiss" | null>(
null,
@@ -64,6 +69,17 @@ export function StarterContentPrompt() {
});
const result = await api.importStarterContent(payload);
// Mirror the `onSettled` pattern used by other mutations
// (useCreatePin / useDeletePin / useReorderPins): the originating
// session invalidates locally so the sidebar + board refresh
// synchronously, independent of the WS round-trip. The server still
// publishes `pin:created` / `project:created` / `issue:created` for
// OTHER sessions; on this session both paths run and the second
// invalidate is a no-op.
qc.invalidateQueries({ queryKey: pinKeys.all(workspace.id, user.id) });
qc.invalidateQueries({ queryKey: projectKeys.all(workspace.id) });
qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) });
// Sync the new starter_content_state into the auth store so this
// component unmounts cleanly on the next render.
await refreshMe();

View File

@@ -6,24 +6,7 @@ import { Card, CardContent } from "@multica/ui/components/ui/card";
const INSTALL_CMD =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
const CLOUD_API_URL = "https://api.multica.ai";
/**
* Build the right `multica setup` command for the current deployment.
*
* - Cloud (api.multica.ai) or no apiUrl hint → plain `multica setup`
* (the CLI hardcodes the cloud endpoints inside setupCloud).
* - Any other apiUrl → `multica setup self-host --server-url ... --app-url ...`
* so dev (localhost) and on-prem both land on THIS server, not the
* public cloud. Dev is just the localhost case of self-host — no
* separate branch needed.
*/
function buildSetupCommand(apiUrl?: string, appUrl?: string): string {
if (!apiUrl || apiUrl === CLOUD_API_URL) return "multica setup";
const appPart = appUrl ? ` --app-url ${appUrl}` : "";
return `multica setup self-host --server-url ${apiUrl}${appPart}`;
}
const SETUP_CMD = "multica setup";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
@@ -50,67 +33,41 @@ function CopyButton({ text }: { text: string }) {
);
}
/**
* CLI install instructions for the runtime step. Web-only by default —
* desktop has a bundled daemon that auto-starts, so install guidance is
* noise there. Rendered as an `instructions` slot inside the CLI dialog.
*
* Structure: two numbered steps shown in natural execution order. Install
* (step 1) MUST come before setup (step 2) — without the `multica` binary
* on PATH, step 2 can't run. A user who already has the CLI can safely
* skip step 1, but the numbering stays for the majority case of a fresh
* install.
*
* The `apiUrl` / `appUrl` props point the setup command at the right
* server. The web shell passes `process.env.NEXT_PUBLIC_API_URL` and
* `window.location.origin`; a self-host / dev deployment gets a
* `multica setup self-host --server-url ... --app-url ...` command;
* cloud gets the plain `multica setup`.
*/
export function CliInstallInstructions({
apiUrl,
appUrl,
}: {
apiUrl?: string;
appUrl?: string;
}) {
const setupCmd = buildSetupCommand(apiUrl, appUrl);
const steps = [
{
label: "Install the Multica CLI",
cmd: INSTALL_CMD,
note: null as string | null,
},
{
label: "Start the daemon",
cmd: setupCmd,
note:
"Opens a browser tab to sign you in, then starts a background daemon. The daemon keeps running after you close the terminal — your agents still pick up tasks.",
},
];
function Step({ n, label, cmd }: { n: number; label: string; cmd: string }) {
return (
<div>
<p className="mb-1.5 text-xs font-medium text-foreground">
{n}. {label}
</p>
<div className="flex items-start gap-2 rounded-lg bg-muted px-3 py-2.5 font-mono text-sm">
<Terminal className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 whitespace-pre-wrap break-all">
{cmd}
</code>
<CopyButton text={cmd} />
</div>
</div>
);
}
/**
* CLI install instructions — two copy-and-run commands. Hardcoded because
* there's nothing environmental to infer: step 1 is the public install
* script, step 2 is the cloud `multica setup` which the CLI itself knows
* the endpoints for. Local development tests a self-host variant by
* typing the extended command directly in the terminal; no need to
* thread env vars through React.
*/
export function CliInstallInstructions() {
return (
<Card className="w-full">
<CardContent className="space-y-4 pt-4">
{steps.map((step, i) => (
<div key={i}>
<p className="mb-1.5 text-xs font-medium text-foreground">
{i + 1}. {step.label}
</p>
<div className="flex items-start gap-2 rounded-lg bg-muted px-3 py-2.5 font-mono text-sm">
<Terminal className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 whitespace-pre-wrap break-all">
{step.cmd}
</code>
<CopyButton text={step.cmd} />
</div>
{step.note && (
<p className="mt-2 text-xs leading-[1.55] text-muted-foreground">
{step.note}
</p>
)}
</div>
))}
<p className="text-xs leading-[1.55] text-muted-foreground">
You&apos;ll need a local AI coding tool (Claude Code, Codex,
Cursor, ) installed for the runtime to do real work.
</p>
<Step n={1} label="Install the Multica CLI" cmd={INSTALL_CMD} />
<Step n={2} label="Start the daemon" cmd={SETUP_CMD} />
</CardContent>
</Card>
);

View File

@@ -371,16 +371,19 @@ function CliInstallDialog({
}) {
return (
<Dialog open={open} onOpenChange={(o) => (o ? null : onClose())}>
<DialogContent className="sm:max-w-[560px]">
{/* max-h + flex column so an unbounded runtime list (N machines)
triggers internal scrolling instead of pushing the footer's
Connect button below the viewport. */}
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Install the CLI</DialogTitle>
<DialogDescription>
Runs the same daemon the desktop app bundles you install
it yourself. This screen watches for it to come online.
it yourself.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pt-2">
{cliInstructions}
{/* Live probe. Shows a staged waiting message with elapsed-
@@ -395,7 +398,10 @@ function CliInstallDialog({
connected
</span>
</div>
<div className="flex flex-col gap-2">
{/* Cap the runtime list at ~4 rows visible, scroll the rest.
Keeps the commands above always reachable even when
a user has many machines registered. */}
<div className="flex max-h-[240px] flex-col gap-2 overflow-y-auto">
{runtimes.map((rt) => (
<CompactRuntimeRow
key={rt.id}
@@ -412,12 +418,16 @@ function CliInstallDialog({
</div>
<DialogFooter className="flex items-center justify-between gap-3 sm:justify-between">
{/* Hint is only useful AFTER a runtime has registered — "pick
one" / "selected X". While still waiting, the body's
CliWaitingStatus already conveys the live-listening state,
so an additional "Waiting..." footer line is duplication. */}
<span className="text-xs text-muted-foreground">
{canConnect && selectedName
? `Selected: ${selectedName}`
: hasRuntimes
? "Pick a runtime above."
: "Waiting for your runtime to come online…"}
{hasRuntimes
? canConnect && selectedName
? `Selected: ${selectedName}`
: "Pick a runtime above."
: null}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>
@@ -577,7 +587,7 @@ function CloudWaitlistDialog({
}) {
return (
<Dialog open={open} onOpenChange={(o) => (o ? null : onClose())}>
<DialogContent className="sm:max-w-[520px]">
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Join the cloud runtime waitlist</DialogTitle>
<DialogDescription>
@@ -586,7 +596,7 @@ function CloudWaitlistDialog({
</DialogDescription>
</DialogHeader>
<div className="pt-2">
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
<CloudWaitlistExpand
submitted={submitted}
onSubmitted={onSubmitted}

View File

@@ -137,7 +137,7 @@ export function StepQuestionnaire({
Before we start
</div>
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
Three questions. Then we tailor the rest.
Three questions to get to know you.
</h1>
<div className="mt-10 flex flex-col gap-7">

View File

@@ -61,9 +61,9 @@ export function StepWelcome({
};
return (
<div className="animate-onboarding-enter grid h-full min-h-[640px] grid-cols-1 lg:grid-cols-2">
<div className="animate-onboarding-enter flex h-full min-h-[640px] flex-col lg:flex-row">
{/* Left — prose + CTA */}
<div className="flex flex-col">
<div className="flex flex-col lg:flex-1">
<DragStrip />
<div className="flex flex-1 flex-col justify-center px-6 pb-12 sm:px-10 md:px-20 lg:px-20 xl:px-24">
<div className="flex w-full max-w-[540px] flex-col gap-8">
@@ -121,10 +121,15 @@ export function StepWelcome({
</div>
</div>
{/* Right — mock issue cards illustration. Hidden on < lg. */}
<div className="hidden border-l bg-muted/40 lg:flex lg:flex-col lg:overflow-hidden">
{/* Right — mock issue cards illustration. Hidden on < lg.
Flex row on lg+ with `items-stretch` (default) makes both
columns take the container's full height, so the muted bg
fills the viewport edge-to-edge. `justify-center` inside
centers the mock cards vertically, mirroring the left
column's copy-center layout. */}
<div className="hidden border-l bg-muted/40 lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<DragStrip />
<div className="flex flex-1 flex-col items-center gap-7 px-8 py-8">
<div className="flex flex-1 flex-col items-center justify-center gap-7 px-8 py-8">
<p className="max-w-[440px] text-balance text-center font-serif text-[15px] italic leading-snug text-muted-foreground">
Every issue, every thread, every decision shared by your team and
agents.

View File

@@ -1,4 +1,4 @@
import { Server, ArrowUpCircle, ChevronDown, Check } from "lucide-react";
import { Server, ArrowUpCircle, ChevronDown, Check, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -81,6 +81,7 @@ export function RuntimeList({
ownerFilter,
onOwnerFilterChange,
updatableIds,
bootstrapping,
}: {
runtimes: AgentRuntime[];
selectedId: string;
@@ -90,6 +91,15 @@ export function RuntimeList({
ownerFilter: string | null;
onOwnerFilterChange: (ownerId: string | null) => void;
updatableIds?: Set<string>;
/**
* When true and no runtimes are visible, the empty state renders a
* "starting" indicator instead of the static "register a runtime"
* hint. The desktop shell sets this while its bundled daemon is
* still booting / registering — without the hint, users see a
* misleading "no runtimes" message during the few seconds between
* page load and daemon registration. Web leaves this undefined.
*/
bootstrapping?: boolean;
}) {
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
@@ -202,19 +212,31 @@ export function RuntimeList({
</div>
{filteredRuntimes.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Server className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">
{filter === "mine" ? "No runtimes owned by you" : ownerFilter ? "No runtimes for this owner" : "No runtimes registered"}
</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Run{" "}
<code className="rounded bg-muted px-1 py-0.5">
multica daemon start
</code>{" "}
to register a local runtime.
</p>
</div>
bootstrapping ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/60" />
<p className="mt-3 text-sm text-muted-foreground">
Starting local runtime
</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
This usually takes a few seconds.
</p>
</div>
) : (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Server className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">
{filter === "mine" ? "No runtimes owned by you" : ownerFilter ? "No runtimes for this owner" : "No runtimes registered"}
</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Run{" "}
<code className="rounded bg-muted px-1 py-0.5">
multica daemon start
</code>{" "}
to register a local runtime.
</p>
</div>
)
) : (
<div className="divide-y">
{filteredRuntimes.map((runtime) => (

View File

@@ -23,9 +23,16 @@ type RuntimeFilter = "mine" | "all";
interface RuntimesPageProps {
/** Desktop-only slot rendered above the runtime list (e.g. local daemon card) */
topSlot?: React.ReactNode;
/**
* Desktop-only signal: the bundled daemon is still booting / hasn't
* registered with the server yet. Forwarded to RuntimeList so its
* empty state shows a "starting" indicator instead of the static
* "register a runtime" hint during the boot window. Web omits this.
*/
bootstrapping?: boolean;
}
export default function RuntimesPage({ topSlot }: RuntimesPageProps = {}) {
export default function RuntimesPage({ topSlot, bootstrapping }: RuntimesPageProps = {}) {
const isLoading = useAuthStore((s) => s.isLoading);
const wsId = useWorkspaceId();
const qc = useQueryClient();
@@ -115,6 +122,7 @@ export default function RuntimesPage({ topSlot }: RuntimesPageProps = {}) {
ownerFilter={ownerFilter}
onOwnerFilterChange={setOwnerFilter}
updatableIds={updatableIds}
bootstrapping={bootstrapping}
/>
</ResizablePanel>

View File

@@ -7,6 +7,7 @@ import (
"net/mail"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
@@ -244,6 +245,16 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return
}
// Reject malformed UUIDs up front. Without this, `parseUUID` below
// silently returns a zero-UUID and the membership check fails with
// a misleading 403 "not a member of this workspace" instead of the
// true 400. Defense-in-depth: even if the membership check is ever
// refactored, a garbage workspace_id never reaches CreateProject /
// CreateIssue.
if _, err := uuid.Parse(req.WorkspaceID); err != nil {
writeError(w, http.StatusBadRequest, "workspace_id is invalid")
return
}
if req.Project.Title == "" {
writeError(w, http.StatusBadRequest, "project.title is required")
return
@@ -410,26 +421,36 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
// --- Pin project (and welcome issue if present) ---
// Non-fatal: a pin failure shouldn't prevent the onboarding bundle
// from landing. We warn and move on.
// from landing. We warn and move on. Pointers to the created rows
// are kept around for post-commit `pin:created` fan-out so the
// sidebar refreshes without a manual reload.
pinnedProjectPos := float64(1)
if _, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
var pinProjectForEvent *db.PinnedItem
pinProject, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
WorkspaceID: parseUUID(req.WorkspaceID),
UserID: parseUUID(userID),
ItemType: "project",
ItemID: project.ID,
Position: pinnedProjectPos,
}); err != nil {
})
if err != nil {
slog.Warn("import starter content: pin project failed", append(logger.RequestAttrs(r), "error", err)...)
} else {
pinProjectForEvent = &pinProject
}
var pinWelcomeIssueForEvent *db.PinnedItem
if welcomeIssueForEvent != nil {
if _, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
pinWelcome, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
WorkspaceID: parseUUID(req.WorkspaceID),
UserID: parseUUID(userID),
ItemType: "issue",
ItemID: welcomeIssueForEvent.ID,
Position: pinnedProjectPos + 1,
}); err != nil {
})
if err != nil {
slog.Warn("import starter content: pin welcome issue failed", append(logger.RequestAttrs(r), "error", err)...)
} else {
pinWelcomeIssueForEvent = &pinWelcome
}
}
@@ -467,6 +488,15 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
subResp := issueToResponse(sub, workspacePrefix)
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": subResp})
}
// Pin events. Without these, the sidebar's `pinListOptions` query
// stays cached on the pre-import snapshot — only a hard refresh
// surfaces the new pins. Same payload shape as `POST /pins`.
if pinProjectForEvent != nil {
h.publish(protocol.EventPinCreated, req.WorkspaceID, "member", userID, map[string]any{"pin": pinnedItemToResponse(*pinProjectForEvent)})
}
if pinWelcomeIssueForEvent != nil {
h.publish(protocol.EventPinCreated, req.WorkspaceID, "member", userID, map[string]any{"pin": pinnedItemToResponse(*pinWelcomeIssueForEvent)})
}
writeJSON(w, http.StatusOK, importStarterContentResponse{
User: userToResponse(updatedUser),

View File

@@ -219,6 +219,11 @@ func (h *Handler) ReorderPins(w http.ResponseWriter, r *http.Request) {
}
}
// Fan out so other sessions (web/desktop, or a second tab) refetch
// the pin list and pick up the new order. Without this, reorder is
// only consistent on the originating client until a hard refresh.
h.publish(protocol.EventPinReordered, workspaceID, "member", userID, map[string]any{"items": req.Items})
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -69,8 +69,9 @@ const (
EventProjectDeleted = "project:deleted"
// Pin events
EventPinCreated = "pin:created"
EventPinDeleted = "pin:deleted"
EventPinCreated = "pin:created"
EventPinDeleted = "pin:deleted"
EventPinReordered = "pin:reordered"
// Invitation events
EventInvitationCreated = "invitation:created"

View File

@@ -24,6 +24,10 @@
"cache": false,
"persistent": true
},
"dev:staging": {
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^typecheck"]
},