Compare commits

...

4 Commits

Author SHA1 Message Date
Naiyuan Qing
253957163b refactor(views): align PropRow labels using CSS subgrid
The fixed `w-16` (64px) label column on PropRow broke whenever a label
rendered wider than 64px (e.g. "Concurrency" in the agent inspector) —
the label would overflow into the gap and collide with the value.

Switch to subgrid: the parent declares `grid grid-cols-[auto_1fr]` and
each PropRow becomes `col-span-2 grid grid-cols-subgrid`. The `auto`
track sizes to the widest label across all rows in that parent, so
labels always fit and value columns stay aligned across rows without
picking a magic pixel width.

Updated parents:
- agent-detail-inspector Section wrapper
- issue-detail Properties group

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:26:54 +08:00
Naiyuan Qing
0f6af09af3 feat(chat): empty-state by session history, no-agent disabled state
Three independent improvements to the chat window's pre-conversation
states, sharing a new three-state availability primitive:

1. New `useWorkspaceAgentAvailability()` hook (`"loading" | "none" |
   "available"`) so callers don't have to reinvent the loading-vs-empty
   distinction. Treating loading as "no agent" — the easy mistake —
   caused the chat input to flash a fake disabled state for the few
   hundred ms after mount, even when the workspace had agents.
2. EmptyState now branches on session history, not agent presence:
   never-chatted users get a short pitch ("They know your workspace —
   issues, projects, skills"), returning users get the existing
   starter prompts. Missing-agent feedback moved to the banner above
   the input, keeping this surface focused on "what is chat for".
3. No-agent disabled state: when availability resolves to "none",
   ChatInput dims and stops responding to clicks/keys, with cursor
   `not-allowed` on hover. The disable lives at the wrapper level
   (`pointer-events-none` on the inner card, `cursor-not-allowed` on
   the outer one — splitting layers so hover bubbles to where the
   browser reads cursor) — we no longer reach into the editor's
   editable mode, which never switched cleanly post-mount anyway.
   A `<NoAgentBanner>` (sibling of OfflineBanner, mutually exclusive)
   states the prerequisite without linking out — no one should be
   pulled out of chat mid-thought to a settings page.

Also: default chat width 420 → 380, since the chat docks at the
bottom-right and 420 was crowding everything else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:26:37 +08:00
Naiyuan Qing
7af34171ca refactor(editor): drop editable prop, ContentEditor is editing-only
ContentEditor's `editable` prop had zero true callsites left in the
codebase — every read-only surface had migrated to ReadonlyContent
(react-markdown), and the prop only invited misuse: Tiptap's
`useEditor` reads `editable` at mount, so callers that toggled it
post-mount (like a chat input that needs to disable on no-agent)
silently got stuck in whichever mode the editor first created.

Changes:
- Remove `editable` prop and default; useEditor and createEditorExtensions
  no longer take it.
- Remove the `"readonly"` className branch and the readonly content sync
  useEffect (only the editing path remains).
- Remove the BubbleMenu and mouseDown editable guards.
- Drop LinkReadonly; rename LinkEditable to LinkExtension and use it
  unconditionally.
- Update the docstring to point readers at ReadonlyContent for display
  surfaces.

ReadonlyContent's `.readonly` CSS class stays in content-editor.css —
that file's selectors are still used by react-markdown's wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:26:11 +08:00
Naiyuan Qing
c91dbd961d fix(onboarding): refresh agent cache after import and agent creation
Two paths could leave the workspace agent-list query cache stale by the
time the dashboard rendered the welcome issue, causing the issue's
agent assignee to resolve to "Unknown Agent":

1. StarterContentPrompt.onImport invalidated pins/projects/issues but
   not agents, and didn't await any of them before navigating — so the
   issue-detail page could mount and read the cache before TanStack
   Query had marked the relevant queries stale.
2. OnboardingFlow.handleAgentCreated created the agent without
   invalidating the agent list, so the dashboard's first mount would
   read whatever was already cached from earlier in onboarding.

Both now invalidate workspaceKeys.agents, and the import flow awaits
all invalidations via Promise.all before pushing the navigation, so
the next page mount always refetches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:25:51 +08:00
13 changed files with 282 additions and 107 deletions

View File

@@ -6,3 +6,4 @@ export * from "./use-agent-activity";
export * from "./use-workspace-presence-prefetch";
export * from "./constants";
export * from "./visibility-label";
export * from "./use-workspace-agent-availability";

View File

@@ -0,0 +1,60 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "../hooks";
import { useAuthStore } from "../auth";
import { agentListOptions, memberListOptions } from "../workspace/queries";
import { canAssignAgentToIssue } from "../permissions";
/**
* Three-state availability for "does the current user have any agent
* they can chat with in this workspace?".
*
* Why three states (not a boolean): the answer to "is there an agent?"
* lives on the server. Until the agent-list query resolves, the answer
* is genuinely *unknown*. Callers must distinguish "loading" from
* "confirmed empty" — collapsing them to a boolean causes UIs to flash
* disabled/empty states for the first few hundred ms after mount, even
* when the workspace actually has agents.
*
* "loading" — agent or member list still in flight (be neutral in UI)
* "none" — both queries resolved, user has zero assignable agents
* "available" — at least one agent passes archive + visibility filters
*/
export type WorkspaceAgentAvailability = "loading" | "none" | "available";
/**
* Mirrors the per-agent visibility/archived filter used by AssigneePicker
* and the chat agent dropdown, so the three pickers can never disagree on
* "is this agent reachable?".
*
* Members are queried because `canAssignAgentToIssue` reads the caller's
* role to decide visibility for `private` agents — without member data,
* a freshly-loaded agent list could still produce wrong answers.
*/
export function useWorkspaceAgentAvailability(): WorkspaceAgentAvailability {
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id);
const { data: agents, isFetched: agentsFetched } = useQuery(
agentListOptions(wsId),
);
const { data: members, isFetched: membersFetched } = useQuery(
memberListOptions(wsId),
);
if (!agentsFetched || !membersFetched) return "loading";
const rawRole = members?.find((m) => m.user_id === userId)?.role;
const role =
rawRole === "owner" || rawRole === "admin" || rawRole === "member"
? rawRole
: null;
const hasVisibleAgent = (agents ?? []).some(
(a) =>
!a.archived_at &&
canAssignAgentToIssue(a, { userId: userId ?? null, role }).allowed,
);
return hasVisibleAgent ? "available" : "none";
}

View File

@@ -51,7 +51,7 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 420;
export const CHAT_DEFAULT_W = 380;
export const CHAT_DEFAULT_H = 600;
/**

View File

@@ -208,11 +208,13 @@ function Section({
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-0.5 border-b px-5 py-4">
<div className="mb-1 px-2 -mx-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
<div className="border-b px-5 py-4">
<div className="mb-1 -mx-2 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
{children}
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
{children}
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { cn } from "@multica/ui/lib/utils";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
@@ -14,6 +15,10 @@ interface ChatInputProps {
onStop?: () => void;
isRunning?: boolean;
disabled?: boolean;
/** True when the user has no agent available — disables the editor and
* surfaces a distinct placeholder. Kept separate from `disabled` so
* archived-session copy stays untouched. */
noAgent?: boolean;
/** Name of the currently selected agent, used in the placeholder. */
agentName?: string;
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
@@ -30,6 +35,7 @@ export function ChatInput({
onStop,
isRunning,
disabled,
noAgent,
agentName,
leftAdornment,
rightAdornment,
@@ -54,11 +60,12 @@ export function ChatInput({
const handleSend = () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || isRunning || disabled) {
if (!content || isRunning || disabled || noAgent) {
logger.debug("input.send skipped", {
emptyContent: !content,
isRunning,
disabled,
noAgent,
});
return;
}
@@ -81,15 +88,38 @@ export function ChatInput({
setIsEmpty(true);
};
const placeholder = disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
const placeholder = noAgent
? "Create an agent to start chatting"
: disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
return (
<div className="px-5 pb-3 pt-0">
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
<div
className={cn(
"px-5 pb-3 pt-0",
// Outer wrapper carries the disabled cursor. Inner card sets
// pointer-events-none, which suppresses hover (and therefore
// any cursor of its own) — splitting the two layers lets hover
// bubble back here so the browser actually reads cursor.
noAgent && "cursor-not-allowed",
)}
>
<div
className={cn(
"relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand",
// Visual + interaction lock when there's no agent. We don't
// toggle ContentEditor's editable mode (Tiptap can't switch
// cleanly post-mount, and the prop has been removed); instead
// we drop pointer events at the wrapper level so clicks miss
// the editor entirely, and dim the surface so it reads as
// "disabled" rather than "broken".
noAgent && "pointer-events-none opacity-60",
)}
aria-disabled={noAgent || undefined}
>
{topSlot}
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
@@ -121,7 +151,7 @@ export function ChatInput({
{rightAdornment}
<SubmitButton
onClick={handleSend}
disabled={isEmpty || !!disabled}
disabled={isEmpty || !!disabled || !!noAgent}
running={isRunning}
onStop={onStop}
/>

View File

@@ -19,9 +19,10 @@ import { useAuthStore } from "@multica/core/auth";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
import { canAssignAgent } from "@multica/views/issues/components";
import { api } from "@multica/core/api";
import { useAgentPresenceDetail } from "@multica/core/agents";
import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
import { ActorAvatar } from "../../common/actor-avatar";
import { OfflineBanner } from "./offline-banner";
import { NoAgentBanner } from "./no-agent-banner";
import {
chatSessionsOptions,
allChatSessionsOptions,
@@ -103,6 +104,13 @@ export function ChatWindow() {
availableAgents[0] ??
null;
// Three-state availability — "loading" stays neutral (no banner, no
// disable) so the input doesn't flash a fake "no agent" state in the
// few hundred ms before the agent list query resolves. Only `"none"`
// (server confirmed: zero usable agents) drives the disabled UI.
const agentAvailability = useWorkspaceAgentAvailability();
const noAgent = agentAvailability === "none";
// Presence drives both the avatar status dot (via ActorAvatar) and the
// OfflineBanner / TaskStatusPill availability copy. `useAgentPresenceDetail`
// returns "loading" while queries are still resolving — pass `undefined`
@@ -425,23 +433,35 @@ export function ChatWindow() {
/>
) : (
<EmptyState
hasSessions={sessions.length > 0}
agentName={activeAgent?.name}
onPickPrompt={(text) => handleSend(text)}
/>
)}
{/* Presence banner sits above the input card (not inside topSlot) so
* the "offline / unstable" hint reads as a global session signal,
* not an attachment to the message being composed. ContextAnchorCard
* stays in topSlot because that's per-message context. */}
<OfflineBanner agentName={activeAgent?.name} availability={availability} />
{/* Status banner above the input — single mutually-exclusive slot.
* Priority: no-agent > offline / unstable. Agent presence is the
* hard prerequisite (you can't send anything without one), so it
* always wins over a presence hint. ContextAnchorCard stays in
* topSlot because that's per-message context, not session state.
*
* We key off `noAgent` (the resolved-empty state) rather than
* `!activeAgent`, so the loading window between mount and the
* first agent-list response stays banner-free. */}
{noAgent ? (
<NoAgentBanner />
) : (
<OfflineBanner agentName={activeAgent?.name} availability={availability} />
)}
{/* Input — disabled for archived sessions */}
{/* Input — disabled for archived sessions; locked out entirely
* when there's no agent (the EmptyState above carries the CTA). */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
noAgent={noAgent}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
@@ -708,12 +728,42 @@ const STARTER_PROMPTS: { icon: string; text: string }[] = [
];
function EmptyState({
hasSessions,
agentName,
onPickPrompt,
}: {
hasSessions: boolean;
agentName?: string;
onPickPrompt: (text: string) => void;
}) {
// First-time experience: the user has never started a chat in this
// workspace. Educate before suggesting actions — starter prompts
// presume the user already knows what chat is for.
//
// Independent of agent state: missing-agent feedback lives in the
// banner above the input, not here. That keeps this surface focused
// on "what is chat" rather than "what's broken right now".
if (!hasSessions) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-8">
<div className="text-center space-y-3">
<h3 className="text-base font-semibold">Chat with your agents</h3>
<p className="text-sm text-muted-foreground">
They know your workspace {" "}
<span className="font-medium text-foreground">
issues, projects, skills
</span>
.
</p>
<p className="text-sm text-muted-foreground">
Ask for a summary, plan your day, or hand off a quick task.
</p>
</div>
</div>
);
}
// Returning user: starter prompts are the fastest path back to action.
return (
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
<div className="text-center space-y-1">

View File

@@ -0,0 +1,29 @@
"use client";
import { Bot } from "lucide-react";
// Sibling of ChatInput, occupying the same banner slot as OfflineBanner.
// Shown when the workspace has no agent the current user can chat with —
// the input above is disabled, and this banner explains why.
//
// Pure copy by design: the banner doesn't link to /agents because the
// information ("you need an agent") is what's actionable here, not the
// destination — pushing users out of chat to a settings page mid-thought
// is more disruptive than just stating the prerequisite. Users who want
// to act go to Agents on their own.
//
// Layout (`px-5` outer, `mx-auto max-w-4xl` inner) mirrors OfflineBanner
// and ChatInput so the banner's edges line up with the input on every
// viewport size.
export function NoAgentBanner() {
return (
<div className="px-5 mb-1.5">
<div className="mx-auto flex w-full max-w-4xl items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border">
<Bot className="size-3.5 shrink-0" />
<span className="truncate">
You need an agent to start chatting.
</span>
</div>
</div>
);
}

View File

@@ -1,8 +1,21 @@
import type { ReactNode } from "react";
/**
* Two-column property row used in detail-page sidebars: a fixed-width muted
* label on the left and a flexible value on the right.
* Two-column property row used in detail-page sidebars: a muted label on the
* left and a flexible value on the right.
*
* Uses **subgrid**, so the parent must declare the column tracks:
*
* <div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
* <PropRow label="…">…</PropRow>
* <PropRow label="…">…</PropRow>
* </div>
*
* The `auto` track sizes to the widest label across all rows in the parent
* grid, so labels always fit and values stay aligned across rows without
* picking a magic pixel width. Earlier versions used a fixed `w-16` label;
* that broke whenever a label (e.g. "Concurrency") rendered wider than 64px
* — the label would overflow into the gap and collide with the value.
*
* `interactive` (default `true`) controls whether the row gets a hover
* highlight. Most rows wrap a Picker/Popover trigger and are clickable
@@ -14,10 +27,6 @@ import type { ReactNode } from "react";
* Used by:
* - issue detail sidebar (Status / Priority / Assignee / …)
* - agent detail inspector (Runtime / Model / Visibility / …)
*
* Width of the label is intentionally narrow (`w-16` = 64px) so even
* 320px-wide sidebars (agent inspector) leave reasonable room for the
* value column.
*/
export function PropRow({
label,
@@ -30,14 +39,12 @@ export function PropRow({
}) {
return (
<div
className={`-mx-2 flex min-h-8 items-center gap-2 rounded-md px-2 ${
className={`-mx-2 col-span-2 grid min-h-8 grid-cols-subgrid items-center rounded-md px-2 ${
interactive ? "transition-colors hover:bg-accent/50" : ""
}`}
>
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{label}
</span>
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-xs">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex min-w-0 items-center gap-1.5 truncate text-xs">
{children}
</div>
</div>

View File

@@ -1,14 +1,19 @@
"use client";
/**
* ContentEditor — the single rich-text editor for the entire application.
* ContentEditor — the rich-text editor used wherever the user TYPES content.
*
* Architecture decisions (April 2026 refactor):
*
* 1. ONE COMPONENT for both editing and readonly display. The `editable` prop
* controls the mode. Previously we had RichTextEditor + ReadonlyEditor as
* separate components with duplicated extension configs — this caused
* visual inconsistency between edit and display modes.
* 1. EDITING ONLY. Read-only display is handled by `ReadonlyContent` (a
* react-markdown renderer), not this component. There used to be an
* `editable` prop here that toggled between modes, but every readonly
* callsite migrated to ReadonlyContent and the prop only invited
* misuse — Tiptap's `useEditor` reads `editable` at mount, so toggling
* the prop later silently failed (mounted-as-readonly editors stayed
* unfocusable forever). To express "currently disabled", wrap this
* component in a layout that sets `pointer-events-none` / `aria-disabled`
* — don't reach into the editor.
*
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
@@ -66,7 +71,6 @@ interface ContentEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
@@ -113,7 +117,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
@@ -131,7 +134,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef(onUploadFile);
const prevContentRef = useRef(defaultValue);
const lastEmittedRef = useRef<string | null>(null);
// Current workspace slug kept in a ref so the click handler always sees the
@@ -154,14 +156,12 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
// Note: in v3.22.1 the default is already false/undefined (same behavior).
// Explicit for clarity — the real perf win is useEditorState in BubbleMenu.
shouldRerenderOnTransaction: false,
editable,
onCreate: ({ editor: ed }) => {
lastEmittedRef.current = stripBlobUrls(ed.getMarkdown());
},
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
contentType: defaultValue ? "markdown" : undefined,
extensions: createEditorExtensions({
editable,
placeholder: placeholderText,
queryClient,
onSubmitRef,
@@ -199,11 +199,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
},
},
attributes: {
class: cn(
"rich-text-editor text-sm outline-none",
!editable && "readonly",
className,
),
class: cn("rich-text-editor text-sm outline-none", className),
},
},
});
@@ -215,20 +211,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
};
}, []);
// Readonly content update: when defaultValue changes and editor is readonly,
// re-set the content (e.g. after editing a comment, the readonly view updates)
useEffect(() => {
if (!editor || editable) return;
if (defaultValue === prevContentRef.current) return;
prevContentRef.current = defaultValue;
const processed = defaultValue ? preprocessMarkdown(defaultValue) : "";
if (processed) {
editor.commands.setContent(processed, { contentType: "markdown" });
} else {
editor.commands.clearContent();
}
}, [editor, editable, defaultValue]);
useImperativeHandle(ref, () => ({
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
clearContent: () => {
@@ -262,7 +244,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const hover = useLinkHover(wrapperRef, hoverDisabled);
const handleContainerMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!editable || !editor) return;
if (!editor) return;
const target = event.target as HTMLElement;
if (target.closest(".ProseMirror")) return;
@@ -281,7 +263,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{editable && showBubbleMenu && (
{showBubbleMenu && (
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
)}
<LinkHoverCard {...hover} />

View File

@@ -49,18 +49,13 @@ import { BlockMathExtension, InlineMathExtension } from "./math";
const lowlight = createLowlight(common);
const LinkEditable = Link.extend({ inclusive: false }).configure({
const LinkExtension = Link.extend({ inclusive: false }).configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
defaultProtocol: "https",
});
const LinkReadonly = Link.configure({
openOnClick: false,
autolink: false,
});
const ImageExtension = Image.extend({
addAttributes() {
return {
@@ -82,7 +77,6 @@ const ImageExtension = Image.extend({
});
export interface EditorExtensionsOptions {
editable: boolean;
placeholder?: string;
queryClient?: import("@tanstack/react-query").QueryClient;
onSubmitRef?: RefObject<(() => void) | undefined>;
@@ -104,9 +98,9 @@ export interface EditorExtensionsOptions {
export function createEditorExtensions(
options: EditorExtensionsOptions,
): AnyExtension[] {
const { editable, placeholder: placeholderText } = options;
const { placeholder: placeholderText } = options;
const extensions: AnyExtension[] = [
return [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
@@ -120,7 +114,7 @@ export function createEditorExtensions(
// ⚠️ Link MUST appear before markdownPaste in this array.
// linkOnPaste relies on Link's handlePaste plugin firing first;
// markdownPaste's handlePaste is a catch-all that returns true.
editable ? LinkEditable : LinkReadonly,
LinkExtension,
ImageExtension,
Table.configure({ resizable: false }),
TableRow,
@@ -130,9 +124,8 @@ export function createEditorExtensions(
InlineMathExtension,
// 3-space indent so nested ordered lists survive CommonMark in ReadonlyContent.
Markdown.configure({ indentation: { style: "space", size: 3 } }),
// Make Cmd+C / Cmd+X / drag write Markdown source to clipboard text/plain.
// Registered for both editable and readonly so users can copy from rendered
// comments and paste the original Markdown elsewhere.
// Make Cmd+C / Cmd+X / drag write Markdown source to clipboard text/plain
// so users can copy rich content out as the original Markdown.
createMarkdownCopyExtension(),
FileCardExtension,
...(options.disableMentions
@@ -140,31 +133,24 @@ export function createEditorExtensions(
: [
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
...(editable && options.queryClient
...(options.queryClient
? { suggestion: createMentionSuggestion(options.queryClient) }
: {}),
}),
]),
Typography,
Placeholder.configure({ placeholder: placeholderText }),
createMarkdownPasteExtension(),
createSubmitExtension(
() => {
const fn = options.onSubmitRef?.current;
if (!fn) return false; // no submit wired — let default Enter insert newline
fn();
return true;
},
{ submitOnEnter: options.submitOnEnter ?? false },
),
createBlurShortcutExtension(),
createFileUploadExtension(options.onUploadFileRef!),
];
if (editable) {
extensions.push(
Typography,
Placeholder.configure({ placeholder: placeholderText }),
createMarkdownPasteExtension(),
createSubmitExtension(
() => {
const fn = options.onSubmitRef?.current;
if (!fn) return false; // no submit wired — let default Enter insert newline
fn();
return true;
},
{ submitOnEnter: options.submitOnEnter ?? false },
),
createBlurShortcutExtension(),
createFileUploadExtension(options.onUploadFileRef!),
);
}
return extensions;
}

View File

@@ -382,7 +382,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
Properties
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${propertiesOpen ? "rotate-90" : ""}`} />
</button>
{propertiesOpen && <div className="space-y-0.5 pl-2">
{propertiesOpen && <div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 pl-2">
<PropRow label="Status">
<StatusPicker status={issue.status} onUpdate={handleUpdateField} align="start" />
</PropRow>

View File

@@ -12,6 +12,7 @@ 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 { workspaceKeys } from "@multica/core/workspace/queries";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
@@ -76,9 +77,21 @@ export function StarterContentPrompt() {
// 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) });
//
// Agents are invalidated too: the server picks the welcome issue's
// assignee from its own agent list, and the issue-detail page we
// navigate to immediately resolves that ID through the cached agent
// list. If the cache is stale (or never populated since
// onboarding-flow created the agent without invalidating), the
// assignee renders as "Unknown Agent". Awaiting Promise.all
// guarantees every relevant query is at least marked stale before
// the navigation kicks in, so the next mount refetches.
await Promise.all([
qc.invalidateQueries({ queryKey: pinKeys.all(workspace.id, user.id) }),
qc.invalidateQueries({ queryKey: projectKeys.all(workspace.id) }),
qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) }),
qc.invalidateQueries({ queryKey: workspaceKeys.agents(workspace.id) }),
]);
// Sync the new starter_content_state into the auth store so this
// component unmounts cleanly on the next render.

View File

@@ -1,7 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
@@ -13,7 +13,7 @@ import {
type OnboardingStep,
type QuestionnaireAnswers,
} from "@multica/core/onboarding";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { workspaceListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import type { Agent, AgentRuntime, Workspace } from "@multica/core/types";
import { DragStrip } from "@multica/views/platform";
import { StepHeader } from "./components/step-header";
@@ -66,6 +66,8 @@ export function OnboardingFlow({
// saved, so every entry starts at Welcome.
const storedQuestionnaire = mergeQuestionnaire(user.onboarding_questionnaire);
const qc = useQueryClient();
const [step, setStep] = useState<OnboardingStep>("welcome");
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [runtime, setRuntime] = useState<AgentRuntime | null>(null);
@@ -141,10 +143,23 @@ export function OnboardingFlow({
setStep(rt ? "agent" : "first_issue");
}, []);
const handleAgentCreated = useCallback((created: Agent) => {
setAgent(created);
setStep("first_issue");
}, []);
const handleAgentCreated = useCallback(
(created: Agent) => {
setAgent(created);
// Mark the workspace's agent list stale so the dashboard's first
// mount refetches and includes the just-created agent. Without
// this, anything resolving an agent ID from the cached list (the
// welcome issue's assignee in particular) renders as "Unknown
// Agent" until something else triggers a refetch.
if (workspace) {
qc.invalidateQueries({
queryKey: workspaceKeys.agents(workspace.id),
});
}
setStep("first_issue");
},
[workspace, qc],
);
const handleBack = useCallback((from: OnboardingStep) => {
const idx = ONBOARDING_STEP_ORDER.indexOf(from);