mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 02:19:19 +02:00
Compare commits
4 Commits
agent/lamb
...
fix/onboar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
253957163b | ||
|
|
0f6af09af3 | ||
|
|
7af34171ca | ||
|
|
c91dbd961d |
@@ -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";
|
||||
|
||||
60
packages/core/agents/use-workspace-agent-availability.ts
Normal file
60
packages/core/agents/use-workspace-agent-availability.ts
Normal 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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
29
packages/views/chat/components/no-agent-banner.tsx
Normal file
29
packages/views/chat/components/no-agent-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user