mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
main
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
305e54e6a0 |
59
packages/core/issues/stores/comment-draft-store.ts
Normal file
59
packages/core/issues/stores/comment-draft-store.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
/**
|
||||
* Persists unsent comment/reply draft text so it survives unmount —
|
||||
* e.g. switching issues, collapsing/expanding a comment card, or toggling
|
||||
* between the main comment box and a reply box.
|
||||
*
|
||||
* Keys are caller-supplied strings:
|
||||
* - `comment:<issueId>` for the main comment input on an issue
|
||||
* - `reply:<issueId>:<commentId>` for the reply input on a specific comment
|
||||
*/
|
||||
interface CommentDraftStore {
|
||||
drafts: Record<string, string>;
|
||||
getDraft: (key: string) => string;
|
||||
setDraft: (key: string, content: string) => void;
|
||||
clearDraft: (key: string) => void;
|
||||
}
|
||||
|
||||
export const useCommentDraftStore = create<CommentDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
getDraft: (key) => get().drafts[key] ?? "",
|
||||
setDraft: (key, content) =>
|
||||
set((s) => {
|
||||
if (!content) {
|
||||
if (!(key in s.drafts)) return s;
|
||||
const { [key]: _, ...rest } = s.drafts;
|
||||
return { drafts: rest };
|
||||
}
|
||||
if (s.drafts[key] === content) return s;
|
||||
return { drafts: { ...s.drafts, [key]: content } };
|
||||
}),
|
||||
clearDraft: (key) =>
|
||||
set((s) => {
|
||||
if (!(key in s.drafts)) return s;
|
||||
const { [key]: _, ...rest } = s.drafts;
|
||||
return { drafts: rest };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_comment_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useCommentDraftStore.persist.rehydrate());
|
||||
|
||||
export function commentDraftKey(issueId: string): string {
|
||||
return `comment:${issueId}`;
|
||||
}
|
||||
|
||||
export function replyDraftKey(issueId: string, commentId: string): string {
|
||||
return `reply:${issueId}:${commentId}`;
|
||||
}
|
||||
@@ -8,6 +8,11 @@ export {
|
||||
} from "./view-store-context";
|
||||
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
|
||||
export { useCommentCollapseStore } from "./comment-collapse-store";
|
||||
export {
|
||||
useCommentDraftStore,
|
||||
commentDraftKey,
|
||||
replyDraftKey,
|
||||
} from "./comment-draft-store";
|
||||
export {
|
||||
myIssuesViewStore,
|
||||
type MyIssuesViewState,
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
import type { TimelineEntry, Attachment } from "@multica/core/types";
|
||||
import { useCommentCollapseStore } from "@multica/core/issues/stores";
|
||||
import { useCommentCollapseStore, replyDraftKey } from "@multica/core/issues/stores";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -567,6 +567,7 @@ function CommentCard({
|
||||
size="sm"
|
||||
avatarType="member"
|
||||
avatarId={currentUserId ?? ""}
|
||||
draftKey={replyDraftKey(issueId, entry.id)}
|
||||
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useCommentDraftStore, commentDraftKey } from "@multica/core/issues/stores";
|
||||
|
||||
interface CommentInputProps {
|
||||
issueId: string;
|
||||
@@ -14,8 +15,12 @@ interface CommentInputProps {
|
||||
}
|
||||
|
||||
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const draftKey = commentDraftKey(issueId);
|
||||
// Read the persisted draft once on mount so we can seed the editor. Later
|
||||
// updates go through setDraft / clearDraft without re-rendering from the store.
|
||||
const initialDraftRef = useRef<string>(useCommentDraftStore.getState().getDraft(draftKey));
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [isEmpty, setIsEmpty] = useState(!initialDraftRef.current.trim());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
@@ -31,6 +36,11 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
|
||||
const handleUpdate = useCallback((md: string) => {
|
||||
setIsEmpty(!md.trim());
|
||||
useCommentDraftStore.getState().setDraft(draftKey, md);
|
||||
}, [draftKey]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
@@ -45,6 +55,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
uploadMapRef.current.clear();
|
||||
useCommentDraftStore.getState().clearDraft(draftKey);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -59,7 +70,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
placeholder="Leave a comment..."
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
defaultValue={initialDraftRef.current}
|
||||
onUpdate={handleUpdate}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
|
||||
@@ -236,6 +236,27 @@ vi.mock("@multica/core/issues/stores", () => ({
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
useCommentDraftStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = {
|
||||
drafts: {},
|
||||
getDraft: () => "",
|
||||
setDraft: () => {},
|
||||
clearDraft: () => {},
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{
|
||||
getState: () => ({
|
||||
drafts: {},
|
||||
getDraft: () => "",
|
||||
setDraft: () => {},
|
||||
clearDraft: () => {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
commentDraftKey: (issueId: string) => `comment:${issueId}`,
|
||||
replyDraftKey: (issueId: string, commentId: string) => `reply:${issueId}:${commentId}`,
|
||||
}));
|
||||
|
||||
// Mock modals
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FileUploadButton } from "@multica/ui/components/common/file-upload-butt
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useCommentDraftStore } from "@multica/core/issues/stores";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -20,6 +21,12 @@ interface ReplyInputProps {
|
||||
avatarId: string;
|
||||
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
size?: "sm" | "default";
|
||||
/**
|
||||
* Stable identifier used to persist the unsent draft across unmount
|
||||
* (e.g. switching issues or collapsing the parent comment). When omitted,
|
||||
* drafts are ephemeral.
|
||||
*/
|
||||
draftKey?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -33,10 +40,17 @@ function ReplyInput({
|
||||
avatarId,
|
||||
onSubmit,
|
||||
size = "default",
|
||||
draftKey,
|
||||
}: ReplyInputProps) {
|
||||
// Seed editor with any persisted draft once on mount. Omitted draftKey →
|
||||
// no persistence, preserving the old ephemeral behavior for callers that
|
||||
// don't want it.
|
||||
const initialDraftRef = useRef<string>(
|
||||
draftKey ? useCommentDraftStore.getState().getDraft(draftKey) : "",
|
||||
);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [isEmpty, setIsEmpty] = useState(!initialDraftRef.current.trim());
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
@@ -64,6 +78,11 @@ function ReplyInput({
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
|
||||
const handleUpdate = useCallback((md: string) => {
|
||||
setIsEmpty(!md.trim());
|
||||
if (draftKey) useCommentDraftStore.getState().setDraft(draftKey, md);
|
||||
}, [draftKey]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
@@ -78,6 +97,7 @@ function ReplyInput({
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
uploadMapRef.current.clear();
|
||||
if (draftKey) useCommentDraftStore.getState().clearDraft(draftKey);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -106,7 +126,8 @@ function ReplyInput({
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
defaultValue={initialDraftRef.current}
|
||||
onUpdate={handleUpdate}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
|
||||
Reference in New Issue
Block a user