@burdenoff/planmagnet-fe-sdk 2026.705.2 → 2026.706.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -881,22 +881,25 @@ interface TaskCommentsCoreProps {
881
881
  entityId: string;
882
882
  /** The current actor — enables "You" + delete on own comments. */
883
883
  currentActorId?: string;
884
- /** Resolve an author id → display name (reuse the member directory). */
884
+ /** Resolve an author/mention id → display name (reuse the member directory). */
885
885
  resolveAssignee?: ResolveAssignee;
886
+ /** Member directory for @-mention autocomplete. Omit to disable mentions. */
887
+ members?: AssigneeOption[];
886
888
  /** Hide the composer + delete affordances (pure read-only). */
887
889
  readonly?: boolean;
888
890
  /** Extra classes on the root container. */
889
891
  className?: string;
890
892
  }
891
893
  /**
892
- * TaskCommentsCore — a comment thread on a work item, backed by the SHARED
893
- * wspace-conversations-svc `EntityComment` (the platform's canonical comment
894
- * owner) rather than a per-product comment table. BYO-Apollo, semantic-token
894
+ * TaskCommentsCore — a threaded comment thread on a work item, backed by the
895
+ * SHARED wspace-conversations-svc `EntityComment` (the platform's canonical
896
+ * comment owner) rather than a per-product comment table. Supports 1-level
897
+ * replies (grouped by thread root) and @-mentions. BYO-Apollo, semantic-token
895
898
  * only, farm-free. RBAC is enforced server-side (create/read are editor-level,
896
899
  * a workspace Member gets editor on `entity-comment`); the composer fails open
897
- * and shows a graceful error if the server denies it.
900
+ * and surfaces a graceful error if the server denies it.
898
901
  */
899
- declare function TaskCommentsCore({ entityType, entityId, currentActorId, resolveAssignee, readonly, className, }: TaskCommentsCoreProps): react.JSX.Element;
902
+ declare function TaskCommentsCore({ entityType, entityId, currentActorId, resolveAssignee, members, readonly, className, }: TaskCommentsCoreProps): react.JSX.Element;
900
903
 
901
904
  /**
902
905
  * TaskLabels — every user-facing string the SDK renders, so a host (e.g.
@@ -969,6 +972,9 @@ interface TaskLabels {
969
972
  editedTag: string;
970
973
  failedToComment: string;
971
974
  failedToDeleteComment: string;
975
+ reply: string;
976
+ replyPlaceholder: string;
977
+ repliesCount: string;
972
978
  create: string;
973
979
  creating: string;
974
980
  cancel: string;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, useMemo, useEffect, useContext, useState, useCallback } from 'react';
1
+ import { createContext, useMemo, useEffect, useContext, useState, useCallback, useRef } from 'react';
2
2
  import { ApolloProvider, useQuery, useApolloClient, useMutation } from '@apollo/client/react';
3
3
  import { createHttpLink, ApolloClient, from, InMemoryCache } from '@apollo/client/core';
4
4
  import { setContext } from '@apollo/client/link/context';
@@ -524,6 +524,12 @@ var ENTITY_COMMENT_FIELDS = gql`
524
524
  edited
525
525
  editedAt
526
526
  status
527
+ parentCommentId
528
+ threadRootId
529
+ threadLevel
530
+ mentions {
531
+ mentionedUserId
532
+ }
527
533
  }
528
534
  `;
529
535
  var GET_ENTITY_COMMENTS = gql`
@@ -798,6 +804,9 @@ var DEFAULT_TASK_LABELS = {
798
804
  editedTag: "(edited)",
799
805
  failedToComment: "Failed to post comment",
800
806
  failedToDeleteComment: "Failed to delete comment",
807
+ reply: "Reply",
808
+ replyPlaceholder: "Write a reply\u2026",
809
+ repliesCount: "{n} replies",
801
810
  create: "Create task",
802
811
  creating: "Creating\u2026",
803
812
  cancel: "Cancel",
@@ -2501,22 +2510,131 @@ function TaskToolbar({
2501
2510
  }
2502
2511
  );
2503
2512
  }
2504
- var labelClass2 = "text-xs font-medium uppercase tracking-wide text-muted-foreground";
2505
2513
  var fieldClass2 = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring";
2514
+ function CommentComposer({
2515
+ placeholder,
2516
+ submitLabel,
2517
+ pendingLabel,
2518
+ members,
2519
+ autoFocus,
2520
+ onSubmit
2521
+ }) {
2522
+ const [text, setText] = useState("");
2523
+ const [pending, setPending] = useState(false);
2524
+ const [mentioned, setMentioned] = useState([]);
2525
+ const [query, setQuery] = useState(null);
2526
+ const taRef = useRef(null);
2527
+ const suggestions = useMemo(() => {
2528
+ if (query == null || !members || members.length === 0) return [];
2529
+ const q = query.toLowerCase();
2530
+ return members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 6);
2531
+ }, [query, members]);
2532
+ const detectMention = (value) => {
2533
+ const caret = taRef.current?.selectionStart ?? value.length;
2534
+ const m = /(?:^|\s)@([\p{L}\p{N} ._-]*)$/u.exec(value.slice(0, caret));
2535
+ setQuery(m ? m[1] : null);
2536
+ };
2537
+ const onChange = (value) => {
2538
+ setText(value);
2539
+ detectMention(value);
2540
+ };
2541
+ const pickMention = (member) => {
2542
+ const el = taRef.current;
2543
+ const caret = el?.selectionStart ?? text.length;
2544
+ const before = text.slice(0, caret);
2545
+ const after = text.slice(caret);
2546
+ const replaced = before.replace(
2547
+ /@([\p{L}\p{N} ._-]*)$/u,
2548
+ `@${member.name} `
2549
+ );
2550
+ setText(replaced + after);
2551
+ setQuery(null);
2552
+ setMentioned(
2553
+ (prev) => prev.some((p) => p.id === member.id) ? prev : [...prev, member]
2554
+ );
2555
+ requestAnimationFrame(() => el?.focus());
2556
+ };
2557
+ const submit = async (e) => {
2558
+ e.preventDefault();
2559
+ const body = text.trim();
2560
+ if (!body || pending) return;
2561
+ const ids = mentioned.filter((m) => body.includes(`@${m.name}`)).map((m) => m.id);
2562
+ setPending(true);
2563
+ try {
2564
+ await onSubmit({ text: body, mentionUserIds: ids });
2565
+ setText("");
2566
+ setMentioned([]);
2567
+ setQuery(null);
2568
+ } finally {
2569
+ setPending(false);
2570
+ }
2571
+ };
2572
+ return /* @__PURE__ */ jsxs("form", { onSubmit: submit, className: "relative space-y-2", children: [
2573
+ /* @__PURE__ */ jsx(
2574
+ "textarea",
2575
+ {
2576
+ ref: taRef,
2577
+ className: cn(fieldClass2, "min-h-[60px]"),
2578
+ rows: 2,
2579
+ value: text,
2580
+ autoFocus,
2581
+ onChange: (e) => onChange(e.target.value),
2582
+ onKeyUp: (e) => detectMention(e.target.value),
2583
+ onClick: (e) => detectMention(e.target.value),
2584
+ placeholder,
2585
+ "aria-label": placeholder
2586
+ }
2587
+ ),
2588
+ suggestions.length > 0 && /* @__PURE__ */ jsx("ul", { className: "absolute left-0 top-full z-20 mt-1 max-h-48 w-56 overflow-auto rounded-md border border-border bg-popover p-1 shadow-md", children: suggestions.map((m) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
2589
+ "button",
2590
+ {
2591
+ type: "button",
2592
+ onMouseDown: (e) => {
2593
+ e.preventDefault();
2594
+ pickMention(m);
2595
+ },
2596
+ className: "flex w-full items-center rounded px-2 py-1 text-left text-sm text-foreground hover:bg-muted",
2597
+ children: [
2598
+ "@",
2599
+ m.name
2600
+ ]
2601
+ }
2602
+ ) }, m.id)) }),
2603
+ /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
2604
+ "button",
2605
+ {
2606
+ type: "submit",
2607
+ disabled: pending || !text.trim(),
2608
+ className: cn(
2609
+ "rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground",
2610
+ (pending || !text.trim()) && "cursor-not-allowed opacity-50"
2611
+ ),
2612
+ children: pending ? pendingLabel : submitLabel
2613
+ }
2614
+ ) })
2615
+ ] });
2616
+ }
2617
+ var labelClass2 = "text-xs font-medium uppercase tracking-wide text-muted-foreground";
2618
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2506
2619
  function TaskCommentsCore({
2507
2620
  entityType,
2508
2621
  entityId,
2509
2622
  currentActorId,
2510
2623
  resolveAssignee,
2624
+ members,
2511
2625
  readonly,
2512
2626
  className
2513
2627
  }) {
2514
2628
  const L = useTaskLabels();
2515
- const [text, setText] = useState("");
2516
2629
  const [error, setError] = useState(null);
2630
+ const [replyingTo, setReplyingTo] = useState(null);
2517
2631
  const { data, loading, refetch } = useQuery(
2518
2632
  GET_ENTITY_COMMENTS,
2519
2633
  {
2634
+ // No parentCommentId → the service returns EVERY comment (all levels)
2635
+ // flat; we build the thread tree client-side via threadRootId. `take` is
2636
+ // capped at 100 by the conversations service (a higher value is rejected
2637
+ // with a validation error), which is ample for a single work item.
2520
2638
  variables: {
2521
2639
  input: {
2522
2640
  entityType,
@@ -2530,16 +2648,30 @@ function TaskCommentsCore({
2530
2648
  errorPolicy: "all"
2531
2649
  }
2532
2650
  );
2533
- const comments = data?.getEntityComments?.items ?? [];
2534
- const [createComment, { loading: posting }] = useMutation(CREATE_ENTITY_COMMENT);
2651
+ const all = data?.getEntityComments?.items ?? [];
2652
+ const { topLevel, repliesByRoot } = useMemo(() => {
2653
+ const top = [];
2654
+ const map = /* @__PURE__ */ new Map();
2655
+ for (const c of all) {
2656
+ if (!c.parentCommentId) {
2657
+ top.push(c);
2658
+ } else {
2659
+ const root = c.threadRootId ?? c.parentCommentId;
2660
+ const arr = map.get(root);
2661
+ if (arr) arr.push(c);
2662
+ else map.set(root, [c]);
2663
+ }
2664
+ }
2665
+ return { topLevel: top, repliesByRoot: map };
2666
+ }, [all]);
2667
+ const [createComment] = useMutation(
2668
+ CREATE_ENTITY_COMMENT
2669
+ );
2535
2670
  const [deleteComment] = useMutation(
2536
2671
  DELETE_ENTITY_COMMENT
2537
2672
  );
2538
2673
  const authorName = (id) => id === currentActorId ? L.you : resolveAssignee?.(id)?.name ?? void 0;
2539
- const handlePost = async (e) => {
2540
- e.preventDefault();
2541
- const body = text.trim();
2542
- if (!body) return;
2674
+ const post = async ({ text, mentionUserIds }, parentCommentId) => {
2543
2675
  setError(null);
2544
2676
  try {
2545
2677
  await createComment({
@@ -2547,15 +2679,18 @@ function TaskCommentsCore({
2547
2679
  input: {
2548
2680
  entityType,
2549
2681
  entityId,
2550
- content: { text: body },
2551
- contentType: "TEXT"
2682
+ content: { text },
2683
+ contentType: "TEXT",
2684
+ ...parentCommentId ? { parentCommentId } : {},
2685
+ ...mentionUserIds.length ? { mentionUserIds } : {}
2552
2686
  }
2553
2687
  }
2554
2688
  });
2555
- setText("");
2556
2689
  await refetch();
2557
- } catch {
2690
+ setReplyingTo(null);
2691
+ } catch (err) {
2558
2692
  setError(L.failedToComment);
2693
+ throw err;
2559
2694
  }
2560
2695
  };
2561
2696
  const handleDelete = async (id) => {
@@ -2567,60 +2702,89 @@ function TaskCommentsCore({
2567
2702
  setError(L.failedToDeleteComment);
2568
2703
  }
2569
2704
  };
2570
- return /* @__PURE__ */ jsxs("div", { className: cn("space-y-3", className), children: [
2571
- /* @__PURE__ */ jsxs("div", { className: labelClass2, children: [
2572
- L.commentsLabel,
2573
- comments.length > 0 ? ` (${comments.length})` : ""
2574
- ] }),
2575
- /* @__PURE__ */ jsx("ul", { className: "space-y-3", children: comments.length === 0 && !loading ? /* @__PURE__ */ jsx("li", { className: "text-sm text-muted-foreground", children: L.noComments }) : comments.map((c) => /* @__PURE__ */ jsxs("li", { className: "flex gap-2", children: [
2576
- /* @__PURE__ */ jsx(Avatar, { id: c.authorId, name: authorName(c.authorId), size: "sm" }),
2577
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
2578
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
2579
- /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: authorName(c.authorId) ?? c.authorId.slice(0, 8) }),
2580
- /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: formatDateFull(c.createdAt) }),
2581
- c.edited && /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: L.editedTag }),
2582
- !readonly && c.authorId === currentActorId && /* @__PURE__ */ jsx(
2583
- "button",
2584
- {
2585
- type: "button",
2586
- onClick: () => handleDelete(c.id),
2587
- "aria-label": L.del,
2588
- title: L.del,
2589
- className: "ml-auto rounded p-1 text-muted-foreground hover:text-destructive",
2590
- children: /* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5" })
2591
- }
2592
- )
2593
- ] }),
2594
- /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words text-sm text-foreground", children: c.content?.text })
2595
- ] })
2596
- ] }, c.id)) }),
2597
- error && /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", children: error }),
2598
- !readonly && /* @__PURE__ */ jsxs("form", { onSubmit: handlePost, className: "space-y-2", children: [
2599
- /* @__PURE__ */ jsx(
2600
- "textarea",
2705
+ const renderBody = (c) => {
2706
+ const text = c.content?.text ?? "";
2707
+ const names = (c.mentions ?? []).map((m) => resolveAssignee?.(m.mentionedUserId)?.name).filter((n) => Boolean(n));
2708
+ if (names.length === 0) return text;
2709
+ const tokens = new Set(names.map((n) => `@${n}`));
2710
+ const re = new RegExp(`(@(?:${names.map(escapeRe).join("|")}))`, "g");
2711
+ return text.split(re).map(
2712
+ (part, i) => tokens.has(part) ? /* @__PURE__ */ jsx(
2713
+ "span",
2601
2714
  {
2602
- className: cn(fieldClass2, "min-h-[60px]"),
2603
- rows: 2,
2604
- value: text,
2605
- onChange: (e) => setText(e.target.value),
2606
- placeholder: L.commentPlaceholder,
2607
- "aria-label": L.commentsLabel
2608
- }
2609
- ),
2610
- /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
2715
+ className: "rounded bg-primary/10 px-0.5 font-medium text-primary",
2716
+ children: part
2717
+ },
2718
+ i
2719
+ ) : /* @__PURE__ */ jsx("span", { children: part }, i)
2720
+ );
2721
+ };
2722
+ const commentBlock = (c, isReply) => /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
2723
+ /* @__PURE__ */ jsx(Avatar, { id: c.authorId, name: authorName(c.authorId), size: "sm" }),
2724
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
2725
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
2726
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: authorName(c.authorId) ?? c.authorId.slice(0, 8) }),
2727
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: formatDateFull(c.createdAt) }),
2728
+ c.edited && /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: L.editedTag }),
2729
+ !readonly && c.authorId === currentActorId && /* @__PURE__ */ jsx(
2730
+ "button",
2731
+ {
2732
+ type: "button",
2733
+ onClick: () => handleDelete(c.id),
2734
+ "aria-label": L.del,
2735
+ title: L.del,
2736
+ className: "ml-auto rounded p-1 text-muted-foreground hover:text-destructive",
2737
+ children: /* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5" })
2738
+ }
2739
+ )
2740
+ ] }),
2741
+ /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words text-sm text-foreground", children: renderBody(c) }),
2742
+ !isReply && !readonly && /* @__PURE__ */ jsx(
2611
2743
  "button",
2612
2744
  {
2613
- type: "submit",
2614
- disabled: posting || !text.trim(),
2615
- className: cn(
2616
- "rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground",
2617
- (posting || !text.trim()) && "cursor-not-allowed opacity-50"
2618
- ),
2619
- children: posting ? L.posting : L.postComment
2745
+ type: "button",
2746
+ onClick: () => setReplyingTo((prev) => prev === c.id ? null : c.id),
2747
+ className: "mt-1 text-xs font-medium text-muted-foreground hover:text-foreground",
2748
+ children: L.reply
2620
2749
  }
2621
- ) })
2750
+ )
2622
2751
  ] })
2623
2752
  ] });
2753
+ return /* @__PURE__ */ jsxs("div", { className: cn("space-y-4", className), children: [
2754
+ /* @__PURE__ */ jsxs("div", { className: labelClass2, children: [
2755
+ L.commentsLabel,
2756
+ all.length > 0 ? ` (${all.length})` : ""
2757
+ ] }),
2758
+ /* @__PURE__ */ jsx("ul", { className: "space-y-4", children: topLevel.length === 0 && !loading ? /* @__PURE__ */ jsx("li", { className: "text-sm text-muted-foreground", children: L.noComments }) : topLevel.map((c) => {
2759
+ const replies = repliesByRoot.get(c.id) ?? [];
2760
+ return /* @__PURE__ */ jsxs("li", { className: "space-y-2", children: [
2761
+ commentBlock(c, false),
2762
+ replies.length > 0 && /* @__PURE__ */ jsx("ul", { className: "ml-8 space-y-2 border-l border-border pl-3", children: replies.map((r) => /* @__PURE__ */ jsx("li", { children: commentBlock(r, true) }, r.id)) }),
2763
+ !readonly && replyingTo === c.id && /* @__PURE__ */ jsx("div", { className: "ml-8", children: /* @__PURE__ */ jsx(
2764
+ CommentComposer,
2765
+ {
2766
+ placeholder: L.replyPlaceholder,
2767
+ submitLabel: L.reply,
2768
+ pendingLabel: L.posting,
2769
+ members,
2770
+ autoFocus: true,
2771
+ onSubmit: (p) => post(p, c.id)
2772
+ }
2773
+ ) })
2774
+ ] }, c.id);
2775
+ }) }),
2776
+ error && /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", children: error }),
2777
+ !readonly && /* @__PURE__ */ jsx(
2778
+ CommentComposer,
2779
+ {
2780
+ placeholder: L.commentPlaceholder,
2781
+ submitLabel: L.postComment,
2782
+ pendingLabel: L.posting,
2783
+ members,
2784
+ onSubmit: (p) => post(p)
2785
+ }
2786
+ )
2787
+ ] });
2624
2788
  }
2625
2789
  var fieldClass3 = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring";
2626
2790
  var labelClass3 = "text-xs font-medium uppercase tracking-wide text-muted-foreground";
@@ -2947,7 +3111,8 @@ function TaskDetailCore({
2947
3111
  entityType: commentsEntityType,
2948
3112
  entityId: item.id,
2949
3113
  currentActorId,
2950
- resolveAssignee
3114
+ resolveAssignee,
3115
+ members: assigneeOptions
2951
3116
  }
2952
3117
  ) })
2953
3118
  ] }) : /* @__PURE__ */ jsxs("form", { onSubmit: handleSave, className: "space-y-4", children: [