Merge pull request #223 from multica-ai/agent/j/36b5c91f

feat(issues): add @ mention for issues in comments
This commit is contained in:
Bohan Jiang 2026-03-31 16:38:12 +08:00 committed by GitHub
commit dbd4830e35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 423 additions and 10 deletions

1
.eslintcache Normal file

File diff suppressed because one or more lines are too long

View file

@ -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<MentionListRef, MentionListProps>(
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="h-3 w-3" />
</span>
) : item.type === "issue" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Hash className="h-3 w-3" />
</span>
) : (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-[9px] font-medium">
{item.label
@ -98,7 +105,12 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
.slice(0, 2)}
</span>
)}
<span className="truncate">{item.label}</span>
<div className="flex flex-col min-w-0">
<span className="truncate">{item.label}</span>
{item.description && (
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
)}
</div>
</button>
))}
</div>
@ -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: () => {

View file

@ -223,6 +223,45 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
const onSubmitRef = useRef(onSubmit);
const onUploadFileRef = useRef(onUploadFile);
// 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 => {
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<string, string>();
// 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<string, string> = {};
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;
onSubmitRef.current = onSubmit;

View file

@ -1,10 +1,11 @@
import * as React from 'react'
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'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
/**
* Render modes for markdown content:
@ -43,6 +44,37 @@ 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)
}
/**
* 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<string, string> = {}
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
@ -67,8 +99,13 @@ function createComponents(
),
// 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' && mentionMatch[2]) {
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
}
return (
<span className="text-primary font-semibold mx-0.5">
{children}
@ -282,14 +319,18 @@ 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 (
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw]}
urlTransform={urlTransform}
components={components}
>
{processedContent}

View file

@ -6,3 +6,4 @@ export { IssuesPage } from "./issues-page";
export { CommentCard } from "./comment-card";
export { CommentInput } from "./comment-input";
export { ReplyInput } from "./reply-input";
export { IssueMentionCard } from "./issue-mention-card";

View file

@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { useIssueStore } from "@/features/issues/store";
import { StatusIcon } from "./status-icon";
interface IssueMentionCardProps {
issueId: string;
/** Fallback text when issue is not in store (e.g. "MUL-7") */
fallbackLabel?: string;
}
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
if (!issue) {
return (
<Link
href={`/issues/${issueId}`}
className="text-primary font-medium cursor-pointer hover:underline"
>
{fallbackLabel ?? issueId.slice(0, 8)}
</Link>
);
}
return (
<Link
href={`/issues/${issueId}`}
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
<span className="text-foreground">{issue.title}</span>
</Link>
);
}

View file

@ -16,7 +16,11 @@ import { useWorkspaceStore } from "@/features/workspace";
import { createLogger } from "@/shared/logger";
import { useRealtimeSync } from "./use-realtime-sync";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
const WS_URL =
process.env.NEXT_PUBLIC_WS_URL ||
(typeof window !== "undefined"
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
: "ws://localhost:8080/ws");
type EventHandler = (payload: unknown) => void;

View file

@ -1,6 +1,24 @@
import type { NextConfig } from "next";
const remoteApiUrl = process.env.REMOTE_API_URL ?? "https://multica-api.copilothub.ai";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${remoteApiUrl}/api/:path*`,
},
{
source: "/ws",
destination: `${remoteApiUrl}/ws`,
},
{
source: "/auth/:path*",
destination: `${remoteApiUrl}/auth/:path*`,
},
];
},
};
export default nextConfig;

View file

@ -5,7 +5,7 @@ export { ApiClient } from "./client";
export type { LoginResponse } from "./client";
export { WSClient } from "./ws-client";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") });

View file

@ -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))

View file

@ -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)
})
}
}

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS daemon_token;

View file

@ -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);

View file

@ -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
}

View file

@ -145,6 +145,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"`

View file

@ -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();