From 7df140bcdab320e59363787cef47b1bf002ea3b4 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 31 Mar 2026 15:38:24 +0800 Subject: [PATCH 01/19] feat(issues): add @ mention for issues in comments Support mentioning issues via @ in the rich text editor with fuzzy search on identifier and title. Issue mentions render as clickable links that navigate to the issue detail page. --- .../components/common/mention-suggestion.tsx | 34 ++++++++++++++++--- .../components/common/rich-text-editor.tsx | 17 +++++++--- apps/web/components/markdown/Markdown.tsx | 16 ++++++++- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/web/components/common/mention-suggestion.tsx b/apps/web/components/common/mention-suggestion.tsx index 0cd09b10..b00b4c1d 100644 --- a/apps/web/components/common/mention-suggestion.tsx +++ b/apps/web/components/common/mention-suggestion.tsx @@ -6,10 +6,11 @@ import { useImperativeHandle, useState, } from "react"; -import { Bot } from "lucide-react"; +import { Bot, Hash } from "lucide-react"; import { ReactRenderer } from "@tiptap/react"; import { computePosition, offset, flip, shift } from "@floating-ui/dom"; import { useWorkspaceStore } from "@/features/workspace"; +import { useIssueStore } from "@/features/issues"; import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion"; // --------------------------------------------------------------------------- @@ -19,7 +20,9 @@ import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion"; export interface MentionItem { id: string; label: string; - type: "member" | "agent"; + type: "member" | "agent" | "issue"; + /** Secondary text shown below the label (e.g. issue title) */ + description?: string; } interface MentionListProps { @@ -88,6 +91,10 @@ const MentionList = forwardRef( + ) : item.type === "issue" ? ( + + + ) : ( {item.label @@ -98,7 +105,12 @@ const MentionList = forwardRef( .slice(0, 2)} )} - {item.label} +
+ {item.label} + {item.description && ( + {item.description} + )} +
))} @@ -117,6 +129,7 @@ export function createMentionSuggestion(): Omit< return { items: ({ query }) => { const { members, agents } = useWorkspaceStore.getState(); + const { issues } = useIssueStore.getState(); const q = query.toLowerCase(); const memberItems: MentionItem[] = members @@ -131,7 +144,20 @@ export function createMentionSuggestion(): Omit< .filter((a) => a.name.toLowerCase().includes(q)) .map((a) => ({ id: a.id, label: a.name, type: "agent" as const })); - return [...memberItems, ...agentItems].slice(0, 10); + const issueItems: MentionItem[] = issues + .filter( + (i) => + i.identifier.toLowerCase().includes(q) || + i.title.toLowerCase().includes(q), + ) + .map((i) => ({ + id: i.id, + label: i.identifier, + type: "issue" as const, + description: i.title, + })); + + return [...memberItems, ...agentItems, ...issueItems].slice(0, 10); }, render: () => { diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 447c4278..9817c03d 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -87,15 +87,17 @@ const MentionExtension = Mention.configure({ suggestion: createMentionSuggestion(), }).extend({ renderHTML({ node, HTMLAttributes }) { + const type = node.attrs.type ?? "member"; + const label = node.attrs.label ?? node.attrs.id; return [ "a", { ...HTMLAttributes, - href: `mention://${node.attrs.type ?? "member"}/${node.attrs.id}`, - "data-mention-type": node.attrs.type ?? "member", + href: `mention://${type}/${node.attrs.id}`, + "data-mention-type": type, "data-mention-id": node.attrs.id, }, - `@${node.attrs.label ?? node.attrs.id}`, + type === "issue" ? label : `@${label}`, ]; }, addAttributes() { @@ -105,14 +107,21 @@ const MentionExtension = Mention.configure({ default: "member", parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member", }, + description: { + default: null, + parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"), + }, }; }, addStorage() { return { markdown: { serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) { + const type = node.attrs.type ?? "member"; + const label = node.attrs.label ?? node.attrs.id; + const display = type === "issue" ? label : `@${label}`; state.write( - `[@${node.attrs.label ?? node.attrs.id}](mention://${node.attrs.type ?? "member"}/${node.attrs.id})`, + `[${display}](mention://${type}/${node.attrs.id})`, ); }, parse: {}, diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 511084da..77f5710b 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import Link from 'next/link' import ReactMarkdown, { type Components } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' @@ -58,8 +59,21 @@ function createComponents( const baseComponents: Partial = { // Links: Make clickable with callbacks, or render as mention a: ({ href, children }) => { - // Mention links: mention://member/id or mention://agent/id + // Mention links: mention://member/id, mention://agent/id, mention://issue/id if (href?.startsWith('mention://')) { + const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/) + if (mentionMatch?.[1] === 'issue') { + const issueId = mentionMatch[2] + return ( + + {children} + + ) + } return ( Date: Tue, 31 Mar 2026 15:52:03 +0800 Subject: [PATCH 02/19] fix(markdown): allow mention:// protocol through URL sanitization react-markdown v10's defaultUrlTransform strips URLs with non-standard protocols (only http/https/irc/mailto/xmpp allowed). This caused mention://issue/ links to have empty hrefs, breaking click navigation to issue detail pages. --- apps/web/components/markdown/Markdown.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 77f5710b..c10fccb3 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import Link from 'next/link' -import ReactMarkdown, { type Components } from 'react-markdown' +import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/utils' @@ -44,6 +44,15 @@ export interface MarkdownProps { onFileClick?: (path: string) => void } +/** + * Custom URL transform that allows mention:// protocol (used for @mentions) + * while keeping the default security for all other URLs. + */ +function urlTransform(url: string): string { + if (url.startsWith('mention://')) return url + return defaultUrlTransform(url) +} + // File path detection regex - matches paths starting with /, ~/, or ./ const FILE_PATH_REGEX = /^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i @@ -298,6 +307,7 @@ export function Markdown({ {processedContent} From a472a0e8e02d6e4bf45119a806ab6a2f7e2d3df1 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 31 Mar 2026 16:09:31 +0800 Subject: [PATCH 03/19] fix(editor): override renderMarkdown/parseMarkdown for mention serialization The @tiptap/markdown extension discovers serializers via the renderMarkdown extension field, not addStorage(). The previous addStorage approach was silently ignored, causing mentions to serialize as shortcode format [@ id="..." label="..."] instead of markdown links. Now properly overrides renderMarkdown, parseMarkdown, and markdownTokenizer to serialize mentions as [@Label](mention://type/id) which the Markdown renderer can handle as clickable links. --- .../components/common/rich-text-editor.tsx | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 9817c03d..acaeb473 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -14,6 +14,7 @@ import Typography from "@tiptap/extension-typography"; import Mention from "@tiptap/extension-mention"; import { Markdown } from "@tiptap/markdown"; import { Extension } from "@tiptap/core"; +import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from "@tiptap/core"; import { cn } from "@/lib/utils"; import { createMentionSuggestion } from "./mention-suggestion"; import "./rich-text-editor.css"; @@ -82,6 +83,9 @@ const LinkExtension = Link.configure({ }, }); +const MENTION_LINK_RE = + /^\[(@?[^\]]*)\]\(mention:\/\/(member|agent|issue)\/([^)]+)\)/; + const MentionExtension = Mention.configure({ HTMLAttributes: { class: "mention" }, suggestion: createMentionSuggestion(), @@ -105,28 +109,58 @@ const MentionExtension = Mention.configure({ ...this.parent?.(), type: { default: "member", - parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member", + parseHTML: (el: HTMLElement) => + el.getAttribute("data-mention-type") ?? "member", }, description: { default: null, - parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"), + parseHTML: (el: HTMLElement) => + el.getAttribute("data-mention-description"), }, }; }, - addStorage() { - return { - markdown: { - serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) { - const type = node.attrs.type ?? "member"; - const label = node.attrs.label ?? node.attrs.id; - const display = type === "issue" ? label : `@${label}`; - state.write( - `[${display}](mention://${type}/${node.attrs.id})`, - ); - }, - parse: {}, - }, - }; + + // -- Markdown serialization: [@Label](mention://type/id) -- + renderMarkdown(node: JSONContent) { + const type = (node.attrs?.type as string) ?? "member"; + const label = (node.attrs?.label as string) ?? node.attrs?.id; + const display = type === "issue" ? label : `@${label}`; + return `[${display}](mention://${type}/${node.attrs?.id})`; + }, + + // -- Markdown parsing: turn the link back into a mention node -- + parseMarkdown(token: MarkdownToken, h: MarkdownParseHelpers) { + return h.createNode("mention", { + id: token.attributes?.id, + label: token.attributes?.label, + type: token.attributes?.type ?? "member", + }); + }, + + markdownTokenizer: { + name: "mention", + level: "inline" as const, + start(src: string) { + // Find [@ or [ followed by ](mention:// + const idx = src.indexOf("](mention://"); + if (idx === -1) return -1; + // Walk back to find the opening [ + const bracketIdx = src.lastIndexOf("[", idx); + return bracketIdx === -1 ? -1 : bracketIdx; + }, + tokenize(src: string) { + const match = MENTION_LINK_RE.exec(src); + if (!match) return undefined; + const [raw, displayLabel = "", type, id] = match; + const label = + displayLabel.startsWith("@") ? displayLabel.slice(1) : displayLabel; + return { + type: "mention", + raw, + content: "", + attributes: { id, label, type }, + }; + }, }, }); From ef4e2d94a0cd9e192070664e54b83861830a383e Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:33:09 +0800 Subject: [PATCH 04/19] feat(issues): add collapsible toggle for comment replies Wrap the replies section in a Collapsible component so users can collapse/expand replies on a comment thread. The parent comment and reply input remain always visible. A chevron trigger shows the reply count (e.g. "3 replies") and rotates on open. Default state is expanded to preserve existing behavior. --- .../issues/components/comment-card.tsx | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index b72baec2..948e0f86 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { Copy, MoreHorizontal, Pencil, Trash2, ChevronRight } from "lucide-react"; import { toast } from "sonner"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -13,9 +13,11 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { ReactionBar } from "@/components/common/reaction-bar"; import { Markdown } from "@/components/markdown"; +import { cn } from "@/lib/utils"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; import { ReplyInput } from "./reply-input"; @@ -189,6 +191,8 @@ function CommentCard({ onDelete, onToggleReaction, }: CommentCardProps) { + const [repliesOpen, setRepliesOpen] = useState(true); + // Collect all nested replies recursively into a flat list const allNestedReplies: TimelineEntry[] = []; const collectReplies = (parentId: string) => { @@ -200,6 +204,8 @@ function CommentCard({ }; collectReplies(entry.id); + const replyCount = allNestedReplies.length; + return ( {/* Parent comment */} @@ -213,18 +219,32 @@ function CommentCard({ /> - {/* Replies — flat, separated by border */} - {allNestedReplies.map((reply) => ( -
- -
- ))} + {/* Replies — collapsible when there are replies */} + {replyCount > 0 && ( + +
+ + + + {replyCount} {replyCount === 1 ? "reply" : "replies"} + + +
+ + {allNestedReplies.map((reply) => ( +
+ +
+ ))} +
+
+ )} {/* Reply input — always visible at bottom */}
From 91c279fd2a908447eb560361b66a5c8220d415e1 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:41:44 +0800 Subject: [PATCH 05/19] feat(issues): make entire comment card collapsible with toggle Each comment card now has a clickable header with a chevron toggle. When collapsed, shows author, timestamp, and a content preview. When expanded, shows the full comment body, replies, and reply input. --- .../issues/components/comment-card.tsx | 108 ++++++++++-------- 1 file changed, 62 insertions(+), 46 deletions(-) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 948e0f86..b2b9727b 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -191,7 +191,8 @@ function CommentCard({ onDelete, onToggleReaction, }: CommentCardProps) { - const [repliesOpen, setRepliesOpen] = useState(true); + const { getActorName } = useActorName(); + const [open, setOpen] = useState(true); // Collect all nested replies recursively into a flat list const allNestedReplies: TimelineEntry[] = []; @@ -205,57 +206,72 @@ function CommentCard({ collectReplies(entry.id); const replyCount = allNestedReplies.length; + const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80); return ( - {/* Parent comment */} -
- -
- - {/* Replies — collapsible when there are replies */} - {replyCount > 0 && ( - -
- - - + + {/* Collapsed header — always visible */} +
+ + + + + {getActorName(entry.actor_type, entry.actor_id)} + + + {timeAgo(entry.created_at)} + + {!open && contentPreview && ( + + {contentPreview}{(entry.content ?? "").length > 80 ? "..." : ""} + + )} + {!open && replyCount > 0 && ( + {replyCount} {replyCount === 1 ? "reply" : "replies"} - -
- - {allNestedReplies.map((reply) => ( -
- -
- ))} -
-
- )} + )} +
+
- {/* Reply input — always visible at bottom */} -
- onReply(entry.id, content)} - /> -
+ {/* Expanded content */} + +
+ +
+ + {/* Replies */} + {allNestedReplies.map((reply) => ( +
+ +
+ ))} + + {/* Reply input */} +
+ onReply(entry.id, content)} + /> +
+
+
); } From 90295d8554fa06f0b863d379a8b6b7f85dd91a44 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:53:05 +0800 Subject: [PATCH 06/19] fix(daemon): add CLI hint to issue_context.md renderIssueContext() now includes a "Quick Start" section with the `multica issue get` command so agents know how to fetch issue details. Fixes the TestPrepareDirectoryMode and TestWriteContextFiles failures. --- server/internal/daemon/execenv/context.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index 58d5e999..c59d56e9 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -120,6 +120,9 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string { b.WriteString("**Trigger:** New Assignment\n\n") } + b.WriteString("## Quick Start\n\n") + fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID) + if len(ctx.AgentSkills) > 0 { b.WriteString("## Agent Skills\n\n") b.WriteString("The following skills are available to you:\n\n") From fc8969a39942e8f6b7c0802a77c9c912c37c7243 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:07:41 +0800 Subject: [PATCH 07/19] fix(issues): remove duplicate commenter header in collapsible comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parent comment's header (avatar, name, time, context menu) is now the collapsible trigger itself. Only the body, reactions, replies, and reply input collapse — the header is always visible. This removes the duplicate author info that appeared when expanded. --- .../issues/components/comment-card.tsx | 144 +++++++++++++++--- 1 file changed, 123 insertions(+), 21 deletions(-) diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index b2b9727b..537b469a 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -193,6 +193,33 @@ function CommentCard({ }: CommentCardProps) { const { getActorName } = useActorName(); const [open, setOpen] = useState(true); + const [editing, setEditing] = useState(false); + const [editContent, setEditContent] = useState(""); + + const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; + const isTemp = entry.id.startsWith("temp-"); + + const startEdit = () => { + setEditContent(entry.content ?? ""); + setEditing(true); + }; + + const cancelEdit = () => { + setEditing(false); + setEditContent(""); + }; + + const saveEdit = async () => { + const trimmed = editContent.trim(); + if (!trimmed) return; + try { + await onEdit(entry.id, trimmed); + setEditing(false); + setEditContent(""); + } catch { + toast.error("Failed to update comment"); + } + }; // Collect all nested replies recursively into a flat list const allNestedReplies: TimelineEntry[] = []; @@ -207,23 +234,36 @@ function CommentCard({ const replyCount = allNestedReplies.length; const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80); + const reactions = entry.reactions ?? []; return ( - + - {/* Collapsed header — always visible */} -
- - - - + {/* Header — always visible, acts as toggle */} +
+
+ + + + + {getActorName(entry.actor_type, entry.actor_id)} - - {timeAgo(entry.created_at)} - + + + {timeAgo(entry.created_at)} + + } + /> + + {new Date(entry.created_at).toLocaleString()} + + + {!open && contentPreview && ( - + {contentPreview}{(entry.content ?? "").length > 80 ? "..." : ""} )} @@ -232,19 +272,81 @@ function CommentCard({ {replyCount} {replyCount === 1 ? "reply" : "replies"} )} - + + {open && !isTemp && ( + + + + + } + /> + + { + navigator.clipboard.writeText(entry.content ?? ""); + toast.success("Copied"); + }}> + + Copy + + {isOwn && ( + <> + + + + Edit + + + onDelete(entry.id)} variant="destructive"> + + Delete + + + )} + + + )} +
- {/* Expanded content */} + {/* Collapsible body */} -
- + {/* Parent comment body */} +
+ {editing ? ( +
{ e.preventDefault(); saveEdit(); }} + className="pl-10" + > + setEditContent(e.target.value)} + aria-label="Edit comment" + className="w-full text-sm bg-transparent border-b border-border outline-none py-1" + onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }} + /> +
+ + +
+
+ ) : ( + <> +
+ {entry.content ?? ""} +
+ {!isTemp && ( + onToggleReaction(entry.id, emoji)} + className="mt-1.5 pl-10" + /> + )} + + )}
{/* Replies */} From d4e121284ab2955c9d0203005deea79327115524 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:07:37 +0800 Subject: [PATCH 08/19] feat(inbox): add archive button on individual inbox list items Show an archive icon on hover for each inbox list item, allowing users to archive a single message directly from the list without needing to open the detail panel first. --- apps/web/app/(dashboard)/inbox/page.tsx | 31 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index a4743a14..9d3b62d2 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -145,15 +145,17 @@ function InboxListItem({ item, isSelected, onClick, + onArchive, }: { item: InboxItem; isSelected: boolean; onClick: () => void; + onArchive: () => void; }) { return (
- {item.issue_status && ( - - )} +
+ { + e.stopPropagation(); + onArchive(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + onArchive(); + } + }} + className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover:inline-flex" + > + + + {item.issue_status && ( + + )} +

@@ -375,6 +397,7 @@ export default function InboxPage() { item={item} isSelected={item.id === selectedId} onClick={() => handleSelect(item)} + onArchive={() => handleArchive(item.id)} /> ))}

From 0eaa6e74a40732653ed26838add9b6b78b196d63 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:15:06 +0800 Subject: [PATCH 09/19] fix(daemon): update execenv tests to match current renderIssueContext output CLI hints like "multica issue get" were moved to CLAUDE.md and are no longer rendered into issue_context.md. Remove stale assertions. --- server/internal/daemon/execenv/execenv_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index db3611f1..f163ef47 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -103,7 +103,7 @@ func TestPrepareDirectoryMode(t *testing.T) { if err != nil { t.Fatalf("failed to read issue_context.md: %v", err) } - for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} { + for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "Code Review"} { if !strings.Contains(string(content), want) { t.Fatalf("issue_context.md missing %q", want) } @@ -208,7 +208,6 @@ func TestWriteContextFiles(t *testing.T) { s := string(content) for _, want := range []string{ "test-issue-id-1234", - "multica issue get", "## Agent Skills", "Go Conventions", } { From 02918d8229b1458720184a7018f89ccf396e5b4c Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:22:58 +0800 Subject: [PATCH 10/19] perf(web): parallelize auth init and non-blocking dashboard layout (#220) - Fire getMe() and listWorkspaces() in parallel instead of serially, saving one network round-trip (~200ms on cloud) - Render dashboard sidebar shell immediately once user is authenticated, show loading indicator in content area while workspace hydrates Closes MUL-41 --- apps/web/app/(dashboard)/layout.tsx | 12 ++++++-- apps/web/features/auth/initializer.tsx | 39 +++++++++++++++++--------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index 52e6f8b6..e9e78d16 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -38,12 +38,20 @@ export default function DashboardLayout({ ); } - if (!user || !workspace) return null; + if (!user) return null; return ( - {children} + + {workspace ? ( + children + ) : ( +
+ +
+ )} +
); } diff --git a/apps/web/features/auth/initializer.tsx b/apps/web/features/auth/initializer.tsx index ae7820c3..ffb0b87a 100644 --- a/apps/web/features/auth/initializer.tsx +++ b/apps/web/features/auth/initializer.tsx @@ -10,26 +10,37 @@ const logger = createLogger("auth"); /** * Initializes auth + workspace state from localStorage on mount. - * Must wrap the app to ensure stores are hydrated before children render. + * Fires getMe() and listWorkspaces() in parallel when a cached token exists. */ export function AuthInitializer({ children }: { children: ReactNode }) { - const initialize = useAuthStore((s) => s.initialize); - const user = useAuthStore((s) => s.user); - const isLoading = useAuthStore((s) => s.isLoading); - const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace); - useEffect(() => { - initialize(); - }, [initialize]); + const token = localStorage.getItem("multica_token"); + if (!token) { + useAuthStore.setState({ isLoading: false }); + return; + } - useEffect(() => { - if (isLoading || !user) return; + api.setToken(token); const wsId = localStorage.getItem("multica_workspace_id"); - api.listWorkspaces().then((wsList) => { - hydrateWorkspace(wsList, wsId); - }).catch((err) => logger.error("workspace hydration failed", err)); - }, [user, isLoading, hydrateWorkspace]); + // Fire getMe and listWorkspaces in parallel + const mePromise = api.getMe(); + const wsPromise = api.listWorkspaces(); + + Promise.all([mePromise, wsPromise]) + .then(([user, wsList]) => { + useAuthStore.setState({ user, isLoading: false }); + useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId); + }) + .catch((err) => { + logger.error("auth init failed", err); + api.setToken(null); + api.setWorkspaceId(null); + localStorage.removeItem("multica_token"); + localStorage.removeItem("multica_workspace_id"); + useAuthStore.setState({ user: null, isLoading: false }); + }); + }, []); return <>{children}; } From 9ceea9c17e94b6a117948f35fbb3f6f1565dcc38 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:23:13 +0800 Subject: [PATCH 11/19] fix(editor): use correct getMarkdown API for @tiptap/markdown (#217) The migration from tiptap-markdown to @tiptap/markdown in 38e92040 broke comment creation. The old package stored getMarkdown() on editor.storage.markdown, but the official @tiptap/markdown extension adds it directly to the editor instance (editor.getMarkdown()). This caused getEditorMarkdown() to always return "", making the submit button permanently disabled and preventing any comments. Also fix stale submitting ref in useIssueTimeline dependency array. --- apps/web/components/common/rich-text-editor.tsx | 4 ++-- apps/web/features/issues/hooks/use-issue-timeline.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index acaeb473..58485ab9 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -203,10 +203,10 @@ const RichTextEditor = forwardRef( const onUpdateRef = useRef(onUpdate); const onSubmitRef = useRef(onSubmit); - // Helper to get markdown from tiptap-markdown storage + // Helper to get markdown from @tiptap/markdown extension // eslint-disable-next-line @typescript-eslint/no-explicit-any const getEditorMarkdown = (ed: any): string => - ed?.storage?.markdown?.getMarkdown?.() ?? ""; + ed?.getMarkdown?.() ?? ""; // Keep refs in sync without recreating editor onUpdateRef.current = onUpdate; diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index 98530d9a..de211e0e 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -202,7 +202,7 @@ export function useIssueTimeline(issueId: string, userId?: string) { setSubmitting(false); } }, - [issueId, userId, submitting], + [issueId, userId], ); const submitReply = useCallback( From dc3dec8ebed7b872e61d32ebf224133b44780310 Mon Sep 17 00:00:00 2001 From: LinYushen Date: Tue, 31 Mar 2026 15:24:05 +0800 Subject: [PATCH 12/19] feat(cli): add multica update command (#218) * feat(cli): add `multica update` command Detects whether multica was installed via Homebrew (by resolving the binary symlink and checking if it lives under a Homebrew prefix). - Brew installs: runs `brew upgrade multica` automatically. - Non-brew installs: prints instructions for installing via brew or downloading from GitHub releases. - Checks latest version from the GitHub releases API and skips the update if already up to date. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): use fully-qualified tap name in brew upgrade Use `brew upgrade multica-ai/tap/multica` instead of `brew upgrade multica` to avoid any potential name collision with core formulae. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- server/cmd/multica/cmd_update.go | 135 +++++++++++++++++++++++++++++++ server/cmd/multica/main.go | 1 + 2 files changed, 136 insertions(+) create mode 100644 server/cmd/multica/cmd_update.go diff --git a/server/cmd/multica/cmd_update.go b/server/cmd/multica/cmd_update.go new file mode 100644 index 00000000..1ed9169e --- /dev/null +++ b/server/cmd/multica/cmd_update.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update multica to the latest version", + RunE: runUpdate, +} + +// githubRelease is the subset of the GitHub releases API response we need. +type githubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` +} + +func runUpdate(_ *cobra.Command, _ []string) error { + fmt.Fprintf(os.Stderr, "Current version: %s (commit: %s)\n", version, commit) + + // Check latest version from GitHub. + latest, err := fetchLatestRelease() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not check latest version: %v\n", err) + } else { + latestVer := strings.TrimPrefix(latest.TagName, "v") + currentVer := strings.TrimPrefix(version, "v") + if currentVer == latestVer { + fmt.Fprintln(os.Stderr, "Already up to date.") + return nil + } + fmt.Fprintf(os.Stderr, "Latest version: %s\n\n", latest.TagName) + } + + // Detect installation method and update accordingly. + if isBrewInstall() { + return updateViaBrew() + } + + // Not installed via brew — show manual instructions. + fmt.Fprintln(os.Stderr, "multica was not installed via Homebrew.") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "To install via Homebrew (recommended):") + fmt.Fprintln(os.Stderr, " brew install multica-ai/tap/multica") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Or download the latest release from:") + fmt.Fprintln(os.Stderr, " https://github.com/multica-ai/multica/releases/latest") + return nil +} + +// isBrewInstall checks whether the running multica binary was installed via Homebrew. +func isBrewInstall() bool { + exePath, err := os.Executable() + if err != nil { + return false + } + // Resolve symlinks (brew links binaries from Cellar into prefix/bin). + resolved, err := filepath.EvalSymlinks(exePath) + if err != nil { + resolved = exePath + } + + // Check if the resolved path is inside a Homebrew prefix. + // Common prefixes: /opt/homebrew (Apple Silicon), /usr/local (Intel Mac), or custom. + brewPrefix := getBrewPrefix() + if brewPrefix != "" && strings.HasPrefix(resolved, brewPrefix) { + return true + } + + // Fallback: check well-known Homebrew paths. + for _, prefix := range []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"} { + if strings.HasPrefix(resolved, prefix+"/Cellar/") { + return true + } + } + return false +} + +// getBrewPrefix returns the Homebrew prefix by running `brew --prefix`, or empty string. +func getBrewPrefix() string { + out, err := exec.Command("brew", "--prefix").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func updateViaBrew() error { + fmt.Fprintln(os.Stderr, "Updating via Homebrew...") + + cmd := exec.Command("brew", "upgrade", "multica-ai/tap/multica") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("brew upgrade failed: %w\nYou can try manually: brew upgrade multica-ai/tap/multica", err) + } + + fmt.Fprintln(os.Stderr, "Update complete.") + return nil +} + +func fetchLatestRelease() (*githubRelease, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/latest", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + return &release, nil +} diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index e8007c49..bf0abbfd 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -34,6 +34,7 @@ func init() { rootCmd.AddCommand(issueCmd) rootCmd.AddCommand(repoCmd) rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(updateCmd) } func main() { From afdfee78b943ce5cfeb27e4fe5b591774ceae284 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 14:41:26 +0800 Subject: [PATCH 13/19] feat(daemon): add authentication for daemon API routes Issue daemon auth tokens (mdt_) on pairing session claim, bound to workspace_id + daemon_id with 1-year expiry. Add DaemonAuth middleware that validates these tokens and falls back to JWT/PAT for backward compatibility. Apply middleware to all daemon routes except pairing endpoints. --- server/cmd/server/router.go | 36 ++++--- server/internal/auth/jwt.go | 9 ++ server/internal/handler/daemon_pairing.go | 40 ++++++- server/internal/middleware/daemon_auth.go | 112 ++++++++++++++++++++ server/migrations/028_daemon_token.down.sql | 1 + server/migrations/028_daemon_token.up.sql | 11 ++ server/pkg/db/generated/daemon_token.sql.go | 88 +++++++++++++++ server/pkg/db/generated/models.go | 9 ++ server/pkg/db/queries/daemon_token.sql | 16 +++ 9 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 server/internal/middleware/daemon_auth.go create mode 100644 server/migrations/028_daemon_token.down.sql create mode 100644 server/migrations/028_daemon_token.up.sql create mode 100644 server/pkg/db/generated/daemon_token.sql.go create mode 100644 server/pkg/db/queries/daemon_token.sql diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 2de70b1e..0f70001d 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -79,28 +79,34 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Post("/auth/send-code", h.SendCode) r.Post("/auth/verify-code", h.VerifyCode) - // Daemon API routes (no user auth; daemon auth deferred to later) + // Daemon API routes r.Route("/api/daemon", func(r chi.Router) { + // Pairing routes — no auth required (daemon doesn't have a token yet). r.Post("/pairing-sessions", h.CreateDaemonPairingSession) r.Get("/pairing-sessions/{token}", h.GetDaemonPairingSession) r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession) - r.Post("/register", h.DaemonRegister) - r.Post("/deregister", h.DaemonDeregister) - r.Post("/heartbeat", h.DaemonHeartbeat) + // Authenticated daemon routes — require daemon token (mdt_) or user JWT/PAT. + r.Group(func(r chi.Router) { + r.Use(middleware.DaemonAuth(queries)) - r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) - r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime) - r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage) - r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult) + r.Post("/register", h.DaemonRegister) + r.Post("/deregister", h.DaemonDeregister) + r.Post("/heartbeat", h.DaemonHeartbeat) - r.Get("/tasks/{taskId}/status", h.GetTaskStatus) - r.Post("/tasks/{taskId}/start", h.StartTask) - r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress) - r.Post("/tasks/{taskId}/complete", h.CompleteTask) - r.Post("/tasks/{taskId}/fail", h.FailTask) - r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages) - r.Get("/tasks/{taskId}/messages", h.ListTaskMessages) + r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) + r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime) + r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage) + r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult) + + r.Get("/tasks/{taskId}/status", h.GetTaskStatus) + r.Post("/tasks/{taskId}/start", h.StartTask) + r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress) + r.Post("/tasks/{taskId}/complete", h.CompleteTask) + r.Post("/tasks/{taskId}/fail", h.FailTask) + r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages) + r.Get("/tasks/{taskId}/messages", h.ListTaskMessages) + }) }) // Protected API routes diff --git a/server/internal/auth/jwt.go b/server/internal/auth/jwt.go index 6ad212ff..f300ed70 100644 --- a/server/internal/auth/jwt.go +++ b/server/internal/auth/jwt.go @@ -37,6 +37,15 @@ func GeneratePATToken() (string, error) { return "mul_" + hex.EncodeToString(b), nil } +// GenerateDaemonToken creates a new daemon auth token: "mdt_" + 40 random hex chars. +func GenerateDaemonToken() (string, error) { + b := make([]byte, 20) // 20 bytes = 40 hex chars + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate daemon token: %w", err) + } + return "mdt_" + hex.EncodeToString(b), nil +} + // HashToken returns the hex-encoded SHA-256 hash of a token string. func HashToken(token string) string { h := sha256.Sum256([]byte(token)) diff --git a/server/internal/handler/daemon_pairing.go b/server/internal/handler/daemon_pairing.go index 9cf7747f..a8bfcfda 100644 --- a/server/internal/handler/daemon_pairing.go +++ b/server/internal/handler/daemon_pairing.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log/slog" "net/http" "net/url" "os" @@ -14,6 +15,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/auth" + db "github.com/multica-ai/multica/server/pkg/db/generated" ) const daemonPairingTTL = 10 * time.Minute @@ -50,6 +53,7 @@ type DaemonPairingSessionResponse struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` LinkURL *string `json:"link_url,omitempty"` + DaemonToken *string `json:"daemon_token,omitempty"` } type CreateDaemonPairingSessionRequest struct { @@ -382,5 +386,39 @@ func (h *Handler) ClaimDaemonPairingSession(w http.ResponseWriter, r *http.Reque return } - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) + resp := daemonPairingSessionToResponse(rec, true) + + // Issue a daemon auth token bound to the workspace and daemon. + if rec.WorkspaceID.Valid { + plainToken, err := auth.GenerateDaemonToken() + if err != nil { + slog.Error("failed to generate daemon token", "error", err) + writeError(w, http.StatusInternalServerError, "failed to generate daemon token") + return + } + hash := auth.HashToken(plainToken) + + // Revoke any existing tokens for this workspace+daemon pair. + _ = h.Queries.DeleteDaemonTokensByWorkspaceAndDaemon(r.Context(), db.DeleteDaemonTokensByWorkspaceAndDaemonParams{ + WorkspaceID: rec.WorkspaceID, + DaemonID: rec.DaemonID, + }) + + _, err = h.Queries.CreateDaemonToken(r.Context(), db.CreateDaemonTokenParams{ + TokenHash: hash, + WorkspaceID: rec.WorkspaceID, + DaemonID: rec.DaemonID, + ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(365 * 24 * time.Hour), Valid: true}, + }) + if err != nil { + slog.Error("failed to store daemon token", "error", err) + writeError(w, http.StatusInternalServerError, "failed to store daemon token") + return + } + + resp.DaemonToken = &plainToken + slog.Info("daemon token issued", "daemon_id", rec.DaemonID, "workspace_id", uuidToPtr(rec.WorkspaceID)) + } + + writeJSON(w, http.StatusOK, resp) } diff --git a/server/internal/middleware/daemon_auth.go b/server/internal/middleware/daemon_auth.go new file mode 100644 index 00000000..d91282cf --- /dev/null +++ b/server/internal/middleware/daemon_auth.go @@ -0,0 +1,112 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/multica-ai/multica/server/internal/auth" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// Daemon context keys. +type daemonContextKey int + +const ( + ctxKeyDaemonWorkspaceID daemonContextKey = iota + ctxKeyDaemonID +) + +// DaemonWorkspaceIDFromContext returns the workspace ID set by DaemonAuth middleware. +func DaemonWorkspaceIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(ctxKeyDaemonWorkspaceID).(string) + return id +} + +// DaemonIDFromContext returns the daemon ID set by DaemonAuth middleware. +func DaemonIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(ctxKeyDaemonID).(string) + return id +} + +// DaemonAuth validates daemon auth tokens (mdt_ prefix) or falls back to +// JWT/PAT validation for backward compatibility with daemons that +// authenticate via user tokens. +func DaemonAuth(queries *db.Queries) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + slog.Debug("daemon_auth: missing authorization header", "path", r.URL.Path) + writeError(w, http.StatusUnauthorized, "missing authorization header") + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + slog.Debug("daemon_auth: invalid format", "path", r.URL.Path) + writeError(w, http.StatusUnauthorized, "invalid authorization format") + return + } + + // Daemon token: "mdt_" prefix. + if strings.HasPrefix(tokenString, "mdt_") { + hash := auth.HashToken(tokenString) + dt, err := queries.GetDaemonTokenByHash(r.Context(), hash) + if err != nil { + slog.Warn("daemon_auth: invalid daemon token", "path", r.URL.Path, "error", err) + writeError(w, http.StatusUnauthorized, "invalid daemon token") + return + } + + ctx := context.WithValue(r.Context(), ctxKeyDaemonWorkspaceID, uuidToString(dt.WorkspaceID)) + ctx = context.WithValue(ctx, ctxKeyDaemonID, dt.DaemonID) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Fallback: PAT tokens ("mul_" prefix). + if strings.HasPrefix(tokenString, "mul_") { + hash := auth.HashToken(tokenString) + pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash) + if err != nil { + slog.Warn("daemon_auth: invalid PAT", "path", r.URL.Path, "error", err) + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + r.Header.Set("X-User-ID", uuidToString(pat.UserID)) + go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID) + next.ServeHTTP(w, r) + return + } + + // Fallback: JWT tokens. + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return auth.JWTSecret(), nil + }) + if err != nil || !token.Valid { + slog.Warn("daemon_auth: invalid token", "path", r.URL.Path, "error", err) + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + writeError(w, http.StatusUnauthorized, "invalid claims") + return + } + sub, ok := claims["sub"].(string) + if !ok || strings.TrimSpace(sub) == "" { + writeError(w, http.StatusUnauthorized, "invalid claims") + return + } + r.Header.Set("X-User-ID", sub) + next.ServeHTTP(w, r) + }) + } +} diff --git a/server/migrations/028_daemon_token.down.sql b/server/migrations/028_daemon_token.down.sql new file mode 100644 index 00000000..18600acc --- /dev/null +++ b/server/migrations/028_daemon_token.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS daemon_token; diff --git a/server/migrations/028_daemon_token.up.sql b/server/migrations/028_daemon_token.up.sql new file mode 100644 index 00000000..6704aa08 --- /dev/null +++ b/server/migrations/028_daemon_token.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE daemon_token ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash TEXT NOT NULL, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + daemon_id TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX idx_daemon_token_hash ON daemon_token(token_hash); +CREATE INDEX idx_daemon_token_workspace_daemon ON daemon_token(workspace_id, daemon_id); diff --git a/server/pkg/db/generated/daemon_token.sql.go b/server/pkg/db/generated/daemon_token.sql.go new file mode 100644 index 00000000..367d7504 --- /dev/null +++ b/server/pkg/db/generated/daemon_token.sql.go @@ -0,0 +1,88 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: daemon_token.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createDaemonToken = `-- name: CreateDaemonToken :one +INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at +` + +type CreateDaemonTokenParams struct { + TokenHash string `json:"token_hash"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + DaemonID string `json:"daemon_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreateDaemonToken(ctx context.Context, arg CreateDaemonTokenParams) (DaemonToken, error) { + row := q.db.QueryRow(ctx, createDaemonToken, + arg.TokenHash, + arg.WorkspaceID, + arg.DaemonID, + arg.ExpiresAt, + ) + var i DaemonToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.WorkspaceID, + &i.DaemonID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const deleteDaemonTokensByWorkspaceAndDaemon = `-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec +DELETE FROM daemon_token +WHERE workspace_id = $1 AND daemon_id = $2 +` + +type DeleteDaemonTokensByWorkspaceAndDaemonParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + DaemonID string `json:"daemon_id"` +} + +func (q *Queries) DeleteDaemonTokensByWorkspaceAndDaemon(ctx context.Context, arg DeleteDaemonTokensByWorkspaceAndDaemonParams) error { + _, err := q.db.Exec(ctx, deleteDaemonTokensByWorkspaceAndDaemon, arg.WorkspaceID, arg.DaemonID) + return err +} + +const deleteExpiredDaemonTokens = `-- name: DeleteExpiredDaemonTokens :exec +DELETE FROM daemon_token +WHERE expires_at <= now() +` + +func (q *Queries) DeleteExpiredDaemonTokens(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteExpiredDaemonTokens) + return err +} + +const getDaemonTokenByHash = `-- name: GetDaemonTokenByHash :one +SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at FROM daemon_token +WHERE token_hash = $1 AND expires_at > now() +` + +func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (DaemonToken, error) { + row := q.db.QueryRow(ctx, getDaemonTokenByHash, tokenHash) + var i DaemonToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.WorkspaceID, + &i.DaemonID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 9547212e..78a736c5 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -131,6 +131,15 @@ type DaemonPairingSession struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type DaemonToken struct { + ID pgtype.UUID `json:"id"` + TokenHash string `json:"token_hash"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + DaemonID string `json:"daemon_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type InboxItem struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` diff --git a/server/pkg/db/queries/daemon_token.sql b/server/pkg/db/queries/daemon_token.sql new file mode 100644 index 00000000..252b17f2 --- /dev/null +++ b/server/pkg/db/queries/daemon_token.sql @@ -0,0 +1,16 @@ +-- name: CreateDaemonToken :one +INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetDaemonTokenByHash :one +SELECT * FROM daemon_token +WHERE token_hash = $1 AND expires_at > now(); + +-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec +DELETE FROM daemon_token +WHERE workspace_id = $1 AND daemon_id = $2; + +-- name: DeleteExpiredDaemonTokens :exec +DELETE FROM daemon_token +WHERE expires_at <= now(); From 94ddbfb4d9a808f6805c4abd839b8a59f7f2fa28 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 31 Mar 2026 15:12:16 +0800 Subject: [PATCH 14/19] fix(tests): merge main, renumber migration, fix execenv test assertions Merge main to pick up 028_task_trigger_comment migration. Renumber daemon_token migration to 029. Fix execenv tests that expected CLI hints in issue_context.md after they were moved to CLAUDE.md. --- .../{028_daemon_token.down.sql => 029_daemon_token.down.sql} | 0 .../{028_daemon_token.up.sql => 029_daemon_token.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename server/migrations/{028_daemon_token.down.sql => 029_daemon_token.down.sql} (100%) rename server/migrations/{028_daemon_token.up.sql => 029_daemon_token.up.sql} (100%) diff --git a/server/migrations/028_daemon_token.down.sql b/server/migrations/029_daemon_token.down.sql similarity index 100% rename from server/migrations/028_daemon_token.down.sql rename to server/migrations/029_daemon_token.down.sql diff --git a/server/migrations/028_daemon_token.up.sql b/server/migrations/029_daemon_token.up.sql similarity index 100% rename from server/migrations/028_daemon_token.up.sql rename to server/migrations/029_daemon_token.up.sql From 8bbc22846946346d60c040f15266f5fcfc2b0411 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:28:02 +0800 Subject: [PATCH 15/19] fix(editor): use correct getMarkdown API for @tiptap/markdown (#219) The migration from tiptap-markdown to @tiptap/markdown (38e92040) changed the getMarkdown API location. The old package stored it at editor.storage.markdown.getMarkdown(), but @tiptap/markdown adds it directly as editor.getMarkdown(). This caused getEditorMarkdown() to always return "", preventing description (and any markdown content) from being saved when creating or editing issues/comments. From 57a5b8b7a4ed46a5c69308202ec6fc0ccf8f13f9 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 31 Mar 2026 14:52:30 +0800 Subject: [PATCH 16/19] feat(web): add OK emoji to reaction quick bar --- apps/web/components/common/reaction-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/common/reaction-bar.tsx b/apps/web/components/common/reaction-bar.tsx index fa98a5fc..f59908b6 100644 --- a/apps/web/components/common/reaction-bar.tsx +++ b/apps/web/components/common/reaction-bar.tsx @@ -10,7 +10,7 @@ const EmojiPicker = lazy(() => import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })), ); -const QUICK_EMOJIS = ["👍", "👎", "❤️", "😄", "🎉", "😕", "🚀", "👀"]; +const QUICK_EMOJIS = ["👍", "👎", "❤️", "😄", "🎉", "😕", "🚀", "👀", "👌"]; interface ReactionItem { id: string; From e0e52bca64d771699fe9f07028f3ab40e5067892 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 31 Mar 2026 15:27:13 +0800 Subject: [PATCH 17/19] feat(web): move OK emoji to 2nd position, remove thumbs down --- apps/web/components/common/reaction-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/common/reaction-bar.tsx b/apps/web/components/common/reaction-bar.tsx index f59908b6..3552a367 100644 --- a/apps/web/components/common/reaction-bar.tsx +++ b/apps/web/components/common/reaction-bar.tsx @@ -10,7 +10,7 @@ const EmojiPicker = lazy(() => import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })), ); -const QUICK_EMOJIS = ["👍", "👎", "❤️", "😄", "🎉", "😕", "🚀", "👀", "👌"]; +const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"]; interface ReactionItem { id: string; From e16f1579a956169e47e531f9c228d21a466c89f5 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 31 Mar 2026 16:18:03 +0800 Subject: [PATCH 18/19] feat(issues): render issue mentions as rich cards with status icon - Fix mention markdown serialization: use renderMarkdown (tiptap/markdown 3.x API) instead of addStorage.markdown.serialize which was silently ignored - Add IssueMentionCard component showing status icon + identifier + title - Update Markdown renderer to use card for mention://issue/ links --- .../components/common/rich-text-editor.tsx | 55 +++---------------- apps/web/components/markdown/Markdown.tsx | 16 ++---- apps/web/features/issues/components/index.ts | 1 + .../issues/components/issue-mention-card.tsx | 37 +++++++++++++ 4 files changed, 49 insertions(+), 60 deletions(-) create mode 100644 apps/web/features/issues/components/issue-mention-card.tsx diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 58485ab9..0cd67ec9 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -14,7 +14,6 @@ import Typography from "@tiptap/extension-typography"; import Mention from "@tiptap/extension-mention"; import { Markdown } from "@tiptap/markdown"; import { Extension } from "@tiptap/core"; -import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from "@tiptap/core"; import { cn } from "@/lib/utils"; import { createMentionSuggestion } from "./mention-suggestion"; import "./rich-text-editor.css"; @@ -83,9 +82,6 @@ const LinkExtension = Link.configure({ }, }); -const MENTION_LINK_RE = - /^\[(@?[^\]]*)\]\(mention:\/\/(member|agent|issue)\/([^)]+)\)/; - const MentionExtension = Mention.configure({ HTMLAttributes: { class: "mention" }, suggestion: createMentionSuggestion(), @@ -109,59 +105,22 @@ const MentionExtension = Mention.configure({ ...this.parent?.(), type: { default: "member", - parseHTML: (el: HTMLElement) => - el.getAttribute("data-mention-type") ?? "member", + parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member", }, description: { default: null, - parseHTML: (el: HTMLElement) => - el.getAttribute("data-mention-description"), + parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"), }, }; }, - - // -- Markdown serialization: [@Label](mention://type/id) -- - renderMarkdown(node: JSONContent) { - const type = (node.attrs?.type as string) ?? "member"; - const label = (node.attrs?.label as string) ?? node.attrs?.id; + // @tiptap/markdown 3.x uses renderMarkdown as a top-level extension field + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderMarkdown(node: any) { + const type = node.attrs?.type ?? "member"; + const label = node.attrs?.label ?? node.attrs?.id; const display = type === "issue" ? label : `@${label}`; return `[${display}](mention://${type}/${node.attrs?.id})`; }, - - // -- Markdown parsing: turn the link back into a mention node -- - parseMarkdown(token: MarkdownToken, h: MarkdownParseHelpers) { - return h.createNode("mention", { - id: token.attributes?.id, - label: token.attributes?.label, - type: token.attributes?.type ?? "member", - }); - }, - - markdownTokenizer: { - name: "mention", - level: "inline" as const, - start(src: string) { - // Find [@ or [ followed by ](mention:// - const idx = src.indexOf("](mention://"); - if (idx === -1) return -1; - // Walk back to find the opening [ - const bracketIdx = src.lastIndexOf("[", idx); - return bracketIdx === -1 ? -1 : bracketIdx; - }, - tokenize(src: string) { - const match = MENTION_LINK_RE.exec(src); - if (!match) return undefined; - const [raw, displayLabel = "", type, id] = match; - const label = - displayLabel.startsWith("@") ? displayLabel.slice(1) : displayLabel; - return { - type: "mention", - raw, - content: "", - attributes: { id, label, type }, - }; - }, - }, }); // --------------------------------------------------------------------------- diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index c10fccb3..6ddff630 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -1,11 +1,11 @@ import * as React from 'react' -import Link from 'next/link' import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/utils' import { CodeBlock, InlineCode } from './CodeBlock' import { preprocessLinks } from './linkify' +import { IssueMentionCard } from '@/features/issues/components/issue-mention-card' /** * Render modes for markdown content: @@ -71,17 +71,9 @@ function createComponents( // Mention links: mention://member/id, mention://agent/id, mention://issue/id if (href?.startsWith('mention://')) { const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/) - if (mentionMatch?.[1] === 'issue') { - const issueId = mentionMatch[2] - return ( - - {children} - - ) + if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) { + const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined + return } return ( s.issues.find((i) => i.id === issueId)); + + if (!issue) { + return ( + + {fallbackLabel ?? issueId.slice(0, 8)} + + ); + } + + return ( + + + {issue.identifier} + {issue.title} + + ); +} From 34ee70029544070a6282c1ffef361b30a06d98b3 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 31 Mar 2026 16:23:11 +0800 Subject: [PATCH 19/19] fix(editor): post-process mention shortcodes to markdown link format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tiptap Mention extension's createInlineMarkdownSpec serializes mentions as shortcodes [@ id="..." label="..."] — the .extend() renderMarkdown override may not reliably take effect. Added a robust fallback: post-process the editor's markdown output by replacing shortcodes with [@Label](mention://type/id) using the Tiptap JSON document for type info. Also preprocess stored shortcodes in the Markdown renderer for backward compatibility. --- .../components/common/rich-text-editor.tsx | 40 +++++++++++++++++-- apps/web/components/markdown/Markdown.tsx | 29 +++++++++++++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 0cd67ec9..a2df7749 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -162,10 +162,44 @@ const RichTextEditor = forwardRef( const onUpdateRef = useRef(onUpdate); const onSubmitRef = useRef(onSubmit); - // Helper to get markdown from @tiptap/markdown extension + // Helper to get markdown from @tiptap/markdown extension. + // Post-processes mention shortcodes [@ id="..." label="..."] → markdown + // links, using the Tiptap JSON doc for type info, in case the + // renderMarkdown override doesn't take effect. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getEditorMarkdown = (ed: any): string => - ed?.getMarkdown?.() ?? ""; + const getEditorMarkdown = (ed: any): string => { + const md: string = ed?.getMarkdown?.() ?? ""; + if (!md || !md.includes("[@ ")) return md; + + // Build type map from editor JSON (which always has the type attr) + const json = ed?.getJSON?.(); + const typeMap = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function walk(node: any) { + if (node?.type === "mention" && node.attrs?.id) { + typeMap.set(node.attrs.id, node.attrs.type || "member"); + } + if (node?.content) node.content.forEach(walk); + } + if (json) walk(json); + + return md.replace( + /\[@\s+([^\]]*)\]/g, + (match: string, attrString: string) => { + const attrs: Record = {}; + const re = /(\w+)="([^"]*)"/g; + let m; + while ((m = re.exec(attrString)) !== null) { + if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2]; + } + const { id, label } = attrs; + if (!id || !label) return match; + const type = typeMap.get(id) || "member"; + const display = type === "issue" ? label : `@${label}`; + return `[${display}](mention://${type}/${id})`; + }, + ); + }; // Keep refs in sync without recreating editor onUpdateRef.current = onUpdate; diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 6ddff630..178f91b7 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -53,6 +53,28 @@ function urlTransform(url: string): string { return defaultUrlTransform(url) } +/** + * Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown + * link format [@LABEL](mention://member/UUID) so they render as styled mentions. + */ +function preprocessMentionShortcodes(text: string): string { + if (!text.includes('[@ ')) return text + return text.replace( + /\[@\s+([^\]]*)\]/g, + (match, attrString: string) => { + const attrs: Record = {} + const re = /(\w+)="([^"]*)"/g + let m + while ((m = re.exec(attrString)) !== null) { + if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2] + } + const { id, label } = attrs + if (!id || !label) return match + return `[@${label}](mention://member/${id})` + } + ) +} + // File path detection regex - matches paths starting with /, ~/, or ./ const FILE_PATH_REGEX = /^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i @@ -291,8 +313,11 @@ export function Markdown({ [mode, onUrlClick, onFileClick] ) - // Preprocess to convert raw URLs and file paths to markdown links - const processedContent = React.useMemo(() => preprocessLinks(children), [children]) + // Preprocess: convert mention shortcodes and raw URLs/file paths to markdown links + const processedContent = React.useMemo( + () => preprocessLinks(preprocessMentionShortcodes(children)), + [children] + ) return (