feat: settings redesign, rich text mentions, inbox listeners, and UI polish

- Refactor settings page into tabbed components (general, workspace, members, tokens, account)
- Move settings link from dropdown to sidebar nav
- Add @mention suggestions in rich text editor
- Expand inbox listeners with enhanced event handling
- Improve board column, issue detail, and create issue modal UX
- Update markdown rendering and code block styling
- Polish skills page layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-26 19:17:14 +08:00
parent a3d20d3644
commit 4052017c7a
27 changed files with 1619 additions and 878 deletions

View file

@ -22,6 +22,7 @@ import {
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarFooter,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
@ -51,6 +52,7 @@ const workspaceNav = [
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/skills", label: "Skills", icon: Sparkles },
{ href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen },
{ href: "/settings", label: "Settings", icon: Settings },
];
export function AppSidebar() {
@ -103,15 +105,6 @@ export function AppSidebar() {
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
render={<Link href="/settings" />}
>
<Settings className="h-3.5 w-3.5" />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup className="group/ws-section">
<DropdownMenuLabel className="flex items-center text-xs text-muted-foreground">
Workspaces
@ -218,6 +211,7 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter />
<SidebarRail />
</Sidebar>
);

View file

@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import { Save } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { api } from "@/shared/api";
export function AccountTab() {
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const [profileName, setProfileName] = useState(user?.name ?? "");
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
const [profileSaving, setProfileSaving] = useState(false);
useEffect(() => {
setProfileName(user?.name ?? "");
setAvatarUrl(user?.avatar_url ?? "");
}, [user]);
const handleProfileSave = async () => {
setProfileSaving(true);
try {
const updated = await api.updateMe({
name: profileName,
avatar_url: avatarUrl || undefined,
});
setUser(updated);
toast.success("Profile updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update profile");
} finally {
setProfileSaving(false);
}
};
return (
<div className="space-y-8">
<section className="space-y-4">
<h2 className="text-sm font-semibold">Profile</h2>
<Card>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
type="search"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Avatar URL</Label>
<Input
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="mt-1"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
onClick={handleProfileSave}
disabled={profileSaving || !profileName.trim()}
>
<Save className="h-3 w-3" />
{profileSaving ? "Updating..." : "Update Profile"}
</Button>
</div>
</CardContent>
</Card>
</section>
</div>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import { useTheme } from "next-themes";
import { Sun, Moon, Monitor } from "lucide-react";
const themeOptions = [
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
] as const;
export function AppearanceTab() {
const { theme, setTheme } = useTheme();
return (
<div className="space-y-8">
<section className="space-y-4">
<h2 className="text-sm font-semibold">Theme</h2>
<div className="flex gap-2">
{themeOptions.map((opt) => {
const active = theme === opt.value;
return (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<opt.icon className="h-3.5 w-3.5" />
{opt.label}
</button>
);
})}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,272 @@
"use client";
import { useState } from "react";
import { Crown, Shield, User, Plus, Trash2, Users } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import type { MemberWithUser, MemberRole } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown }> = {
owner: { label: "Owner", icon: Crown },
admin: { label: "Admin", icon: Shield },
member: { label: "Member", icon: User },
};
function MemberRow({
member,
canManage,
canManageOwners,
isSelf,
busy,
onRoleChange,
onRemove,
}: {
member: MemberWithUser;
canManage: boolean;
canManageOwners: boolean;
isSelf: boolean;
busy: boolean;
onRoleChange: (role: MemberRole) => void;
onRemove: () => void;
}) {
const rc = roleConfig[member.role];
const RoleIcon = rc.icon;
const canEditRole = canManage && (!isSelf || canManageOwners) && (member.role !== "owner" || canManageOwners);
const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
return (
<Card>
<CardContent className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{member.name}</div>
<div className="text-xs text-muted-foreground truncate">{member.email}</div>
</div>
{canEditRole ? (
<Select value={member.role} onValueChange={(value) => onRoleChange(value as MemberRole)} disabled={busy}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{canManageOwners && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RoleIcon className="h-3 w-3" />
{rc.label}
</div>
)}
{canRemove && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
onClick={onRemove}
disabled={busy}
aria-label={`Remove ${member.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
/>
<TooltipContent>Remove member</TooltipContent>
</Tooltip>
)}
</CardContent>
</Card>
);
}
export function MembersTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
const [inviteLoading, setInviteLoading] = useState(false);
const [memberActionId, setMemberActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
title: string;
description: string;
variant?: "destructive";
onConfirm: () => Promise<void>;
} | null>(null);
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
const handleAddMember = async () => {
if (!workspace) return;
setInviteLoading(true);
try {
await api.createMember(workspace.id, {
email: inviteEmail,
role: inviteRole,
});
setInviteEmail("");
setInviteRole("member");
await refreshMembers();
toast.success("Member added");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add member");
} finally {
setInviteLoading(false);
}
};
const handleRoleChange = async (memberId: string, role: MemberRole) => {
if (!workspace) return;
setMemberActionId(memberId);
try {
await api.updateMember(workspace.id, memberId, { role });
await refreshMembers();
toast.success("Role updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update member");
} finally {
setMemberActionId(null);
}
};
const handleRemoveMember = (member: MemberWithUser) => {
if (!workspace) return;
setConfirmAction({
title: `Remove ${member.name}`,
description: `Remove ${member.name} from ${workspace.name}? They will lose access to this workspace.`,
variant: "destructive",
onConfirm: async () => {
setMemberActionId(member.id);
try {
await api.deleteMember(workspace.id, member.id);
await refreshMembers();
toast.success("Member removed");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member");
} finally {
setMemberActionId(null);
}
},
});
};
if (!workspace) return null;
return (
<div className="space-y-8">
<section className="space-y-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Members ({members.length})</h2>
</div>
{canManageWorkspace && (
<Card>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Plus className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Add member</h3>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="user@company.com"
/>
<Select value={inviteRole} onValueChange={(value) => setInviteRole(value as MemberRole)}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{isOwner && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
<Button
onClick={handleAddMember}
disabled={inviteLoading || !inviteEmail.trim()}
>
{inviteLoading ? "Adding..." : "Add"}
</Button>
</div>
</CardContent>
</Card>
)}
<div className="space-y-2">
{members.map((m) => (
<MemberRow
key={m.id}
member={m}
canManage={canManageWorkspace}
canManageOwners={isOwner}
isSelf={m.user_id === user?.id}
busy={memberActionId === m.id}
onRoleChange={(role) => handleRoleChange(m.id, role)}
onRemove={() => handleRemoveMember(m)}
/>
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground">No members found.</p>
)}
</div>
</section>
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmAction?.title}</AlertDialogTitle>
<AlertDialogDescription>{confirmAction?.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={confirmAction?.variant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""}
onClick={async () => {
await confirmAction?.onConfirm();
setConfirmAction(null);
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -0,0 +1,185 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Key, Trash2, Copy, Check } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import type { PersonalAccessToken } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { api } from "@/shared/api";
export function TokensTab() {
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
const [tokenName, setTokenName] = useState("");
const [tokenExpiry, setTokenExpiry] = useState("90");
const [tokenCreating, setTokenCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
const loadTokens = useCallback(async () => {
try {
const list = await api.listPersonalAccessTokens();
setTokens(list);
} catch {
// ignore — tokens section simply stays empty
}
}, []);
useEffect(() => { loadTokens(); }, [loadTokens]);
const handleCreateToken = async () => {
setTokenCreating(true);
try {
const expiresInDays = tokenExpiry === "never" ? undefined : Number(tokenExpiry);
const result = await api.createPersonalAccessToken({ name: tokenName, expires_in_days: expiresInDays });
setNewToken(result.token);
setTokenName("");
setTokenExpiry("90");
await loadTokens();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create token");
} finally {
setTokenCreating(false);
}
};
const handleRevokeToken = async (id: string) => {
setTokenRevoking(id);
try {
await api.revokePersonalAccessToken(id);
await loadTokens();
toast.success("Token revoked");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to revoke token");
} finally {
setTokenRevoking(null);
}
};
const handleCopyToken = async () => {
if (!newToken) return;
await navigator.clipboard.writeText(newToken);
setTokenCopied(true);
setTimeout(() => setTokenCopied(false), 2000);
};
return (
<div className="space-y-8">
<section className="space-y-4">
<div className="flex items-center gap-2">
<Key className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">API Tokens</h2>
</div>
<Card>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
Personal access tokens allow the CLI and external integrations to authenticate with your account.
</p>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<Input
type="text"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="Token name (e.g. My CLI)"
/>
<Select value={tokenExpiry} onValueChange={(v) => { if (v) setTokenExpiry(v); }}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
<SelectItem value="365">1 year</SelectItem>
<SelectItem value="never">No expiry</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreateToken} disabled={tokenCreating || !tokenName.trim()}>
{tokenCreating ? "Creating..." : "Create"}
</Button>
</div>
</CardContent>
</Card>
{tokens.length > 0 && (
<div className="space-y-2">
{tokens.map((t) => (
<Card key={t.id}>
<CardContent className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{t.name}</div>
<div className="text-xs text-muted-foreground">
{t.token_prefix}... · Created {new Date(t.created_at).toLocaleDateString()} · {t.last_used_at ? `Last used ${new Date(t.last_used_at).toLocaleDateString()}` : "Never used"}
{t.expires_at && ` · Expires ${new Date(t.expires_at).toLocaleDateString()}`}
</div>
</div>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRevokeToken(t.id)}
disabled={tokenRevoking === t.id}
aria-label={`Revoke ${t.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
/>
<TooltipContent>Revoke</TooltipContent>
</Tooltip>
</CardContent>
</Card>
))}
</div>
)}
</section>
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Token created</DialogTitle>
<DialogDescription>
Copy your personal access token now. You won&apos;t be able to see it again.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted/50 px-3 py-2 text-sm break-all select-all">
{newToken}
</code>
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon" onClick={handleCopyToken}>
{tokenCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
}
/>
<TooltipContent>Copy token</TooltipContent>
</Tooltip>
</div>
<DialogFooter>
<Button onClick={() => { setNewToken(null); setTokenCopied(false); }}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,248 @@
"use client";
import { useEffect, useState } from "react";
import { Save, LogOut } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(workspace?.description ?? "");
const [context, setContext] = useState(workspace?.context ?? "");
const [saving, setSaving] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
title: string;
description: string;
variant?: "destructive";
onConfirm: () => Promise<void>;
} | null>(null);
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
useEffect(() => {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
setContext(workspace?.context ?? "");
}, [workspace]);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
try {
const updated = await api.updateWorkspace(workspace.id, {
name,
description,
context,
});
updateWorkspace(updated);
toast.success("Workspace settings saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save workspace settings");
} finally {
setSaving(false);
}
};
const handleLeaveWorkspace = () => {
if (!workspace) return;
setConfirmAction({
title: "Leave workspace",
description: `Leave ${workspace.name}? You will lose access until re-invited.`,
variant: "destructive",
onConfirm: async () => {
setActionId("leave");
try {
await leaveWorkspace(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to leave workspace");
} finally {
setActionId(null);
}
},
});
};
const handleDeleteWorkspace = () => {
if (!workspace) return;
setConfirmAction({
title: "Delete workspace",
description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`,
variant: "destructive",
onConfirm: async () => {
setActionId("delete-workspace");
try {
await deleteWorkspace(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
} finally {
setActionId(null);
}
},
});
};
if (!workspace) return null;
return (
<div className="space-y-8">
{/* Workspace settings */}
<section className="space-y-4">
<h2 className="text-sm font-semibold">General</h2>
<Card>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!canManageWorkspace}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
disabled={!canManageWorkspace}
className="mt-1 resize-none"
placeholder="What does this workspace focus on?"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Context</Label>
<Textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={4}
disabled={!canManageWorkspace}
className="mt-1 resize-none"
placeholder="Background information and context for AI agents working in this workspace"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Slug</Label>
<div className="mt-1 rounded-md border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
{workspace.slug}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
{!canManageWorkspace && (
<p className="text-xs text-muted-foreground">
Only admins and owners can update workspace settings.
</p>
)}
</CardContent>
</Card>
</section>
{/* Danger Zone */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<LogOut className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Danger Zone</h2>
</div>
<Card>
<CardContent className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">Leave workspace</p>
<p className="text-xs text-muted-foreground">
Remove yourself from this workspace.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleLeaveWorkspace}
disabled={actionId === "leave"}
>
{actionId === "leave" ? "Leaving..." : "Leave workspace"}
</Button>
</div>
{isOwner && (
<div className="flex flex-col gap-2 border-t pt-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-destructive">Delete workspace</p>
<p className="text-xs text-muted-foreground">
Permanently delete this workspace and its data.
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteWorkspace}
disabled={actionId === "delete-workspace"}
>
{actionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
</Button>
</div>
)}
</CardContent>
</Card>
</section>
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmAction?.title}</AlertDialogTitle>
<AlertDialogDescription>{confirmAction?.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={confirmAction?.variant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""}
onClick={async () => {
await confirmAction?.onConfirm();
setConfirmAction(null);
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -1,673 +1,68 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut, Key, Copy, Check } from "lucide-react";
import type { MemberWithUser, MemberRole, PersonalAccessToken } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { User, Palette, Key, Settings, Users } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import { AccountTab } from "./_components/account-tab";
import { AppearanceTab } from "./_components/general-tab";
import { TokensTab } from "./_components/tokens-tab";
import { WorkspaceTab } from "./_components/workspace-tab";
import { MembersTab } from "./_components/members-tab";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown }> = {
owner: { label: "Owner", icon: Crown },
admin: { label: "Admin", icon: Shield },
member: { label: "Member", icon: User },
};
const accountTabs = [
{ value: "profile", label: "Profile", icon: User },
{ value: "appearance", label: "Appearance", icon: Palette },
{ value: "tokens", label: "API Tokens", icon: Key },
];
function MemberRow({
member,
canManage,
canManageOwners,
isSelf,
busy,
onRoleChange,
onRemove,
}: {
member: MemberWithUser;
canManage: boolean;
canManageOwners: boolean;
isSelf: boolean;
busy: boolean;
onRoleChange: (role: MemberRole) => void;
onRemove: () => void;
}) {
const rc = roleConfig[member.role];
const RoleIcon = rc.icon;
const canEditRole = canManage && (!isSelf || canManageOwners) && (member.role !== "owner" || canManageOwners);
const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
return (
<div className="flex items-center gap-3 rounded-lg border px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{member.name}</div>
<div className="text-xs text-muted-foreground truncate">{member.email}</div>
</div>
{canEditRole ? (
<Select value={member.role} onValueChange={(value) => onRoleChange(value as MemberRole)} disabled={busy}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{canManageOwners && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RoleIcon className="h-3 w-3" />
{rc.label}
</div>
)}
{canRemove && (
<Button
variant="ghost"
size="icon-sm"
onClick={onRemove}
disabled={busy}
aria-label={`Remove ${member.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
}
const workspaceTabs = [
{ value: "workspace", label: "General", icon: Settings },
{ value: "members", label: "Members", icon: Users },
];
export default function SettingsPage() {
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(
workspace?.description ?? "",
);
const [context, setContext] = useState(workspace?.context ?? "");
const [profileName, setProfileName] = useState(user?.name ?? "");
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
const [saving, setSaving] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
const [tokenName, setTokenName] = useState("");
const [tokenExpiry, setTokenExpiry] = useState("90");
const [tokenCreating, setTokenCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
const loadTokens = useCallback(async () => {
try {
const list = await api.listPersonalAccessTokens();
setTokens(list);
} catch {
// ignore — tokens section simply stays empty
}
}, []);
useEffect(() => { loadTokens(); }, [loadTokens]);
const handleCreateToken = async () => {
setTokenCreating(true);
try {
const expiresInDays = tokenExpiry === "never" ? undefined : Number(tokenExpiry);
const result = await api.createPersonalAccessToken({ name: tokenName, expires_in_days: expiresInDays });
setNewToken(result.token);
setTokenName("");
setTokenExpiry("90");
await loadTokens();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create token");
} finally {
setTokenCreating(false);
}
};
const handleRevokeToken = async (id: string) => {
setTokenRevoking(id);
try {
await api.revokePersonalAccessToken(id);
await loadTokens();
toast.success("Token revoked");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to revoke token");
} finally {
setTokenRevoking(null);
}
};
const handleCopyToken = async () => {
if (!newToken) return;
await navigator.clipboard.writeText(newToken);
setTokenCopied(true);
setTimeout(() => setTokenCopied(false), 2000);
};
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
const [inviteLoading, setInviteLoading] = useState(false);
const [memberActionId, setMemberActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
title: string;
description: string;
variant?: "destructive";
onConfirm: () => Promise<void>;
} | null>(null);
const currentMember = members.find((member) => member.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
useEffect(() => {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
setContext(workspace?.context ?? "");
}, [workspace]);
useEffect(() => {
setProfileName(user?.name ?? "");
setAvatarUrl(user?.avatar_url ?? "");
}, [user]);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
try {
const updated = await api.updateWorkspace(workspace.id, {
name,
description,
context,
});
updateWorkspace(updated);
toast.success("Workspace settings saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save workspace settings");
} finally {
setSaving(false);
}
};
const handleProfileSave = async () => {
setProfileSaving(true);
try {
const updated = await api.updateMe({
name: profileName,
avatar_url: avatarUrl || undefined,
});
setUser(updated);
toast.success("Profile updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update profile");
} finally {
setProfileSaving(false);
}
};
const handleAddMember = async () => {
if (!workspace) return;
setInviteLoading(true);
try {
await api.createMember(workspace.id, {
email: inviteEmail,
role: inviteRole,
});
setInviteEmail("");
setInviteRole("member");
await refreshMembers();
toast.success("Member added");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add member");
} finally {
setInviteLoading(false);
}
};
const handleRoleChange = async (memberId: string, role: MemberRole) => {
if (!workspace) return;
setMemberActionId(memberId);
try {
await api.updateMember(workspace.id, memberId, { role });
await refreshMembers();
toast.success("Role updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update member");
} finally {
setMemberActionId(null);
}
};
const handleRemoveMember = (member: MemberWithUser) => {
if (!workspace) return;
setConfirmAction({
title: `Remove ${member.name}`,
description: `Remove ${member.name} from ${workspace.name}? They will lose access to this workspace.`,
variant: "destructive",
onConfirm: async () => {
setMemberActionId(member.id);
try {
await api.deleteMember(workspace.id, member.id);
await refreshMembers();
toast.success("Member removed");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member");
} finally {
setMemberActionId(null);
}
},
});
};
const handleLeaveWorkspace = () => {
if (!workspace) return;
setConfirmAction({
title: "Leave workspace",
description: `Leave ${workspace.name}? You will lose access until re-invited.`,
variant: "destructive",
onConfirm: async () => {
setMemberActionId("leave");
try {
await leaveWorkspace(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to leave workspace");
} finally {
setMemberActionId(null);
}
},
});
};
const handleDeleteWorkspace = () => {
if (!workspace) return;
setConfirmAction({
title: "Delete workspace",
description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`,
variant: "destructive",
onConfirm: async () => {
setMemberActionId("delete-workspace");
try {
await deleteWorkspace(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
} finally {
setMemberActionId(null);
}
},
});
};
if (!workspace) return null;
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="mx-auto max-w-2xl p-6 space-y-8">
{/* Page header */}
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Settings</h1>
<Tabs defaultValue="profile" orientation="vertical" className="flex-1 min-h-0 gap-0">
{/* Left nav */}
<div className="w-52 shrink-0 border-r overflow-y-auto p-4">
<h1 className="text-sm font-semibold mb-4 px-2">Settings</h1>
<TabsList variant="line" className="flex-col items-stretch">
{/* My Account group */}
<span className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground">
My Account
</span>
{accountTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
{/* Workspace group */}
<span className="px-2 pb-1 pt-4 text-xs font-medium text-muted-foreground truncate">
{workspaceName ?? "Workspace"}
</span>
{workspaceTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
<section className="space-y-4">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Profile</h2>
{/* Right content */}
<div className="flex-1 min-w-0 overflow-y-auto">
<div className="w-full max-w-3xl mx-auto p-6">
<TabsContent value="profile"><AccountTab /></TabsContent>
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
<TabsContent value="tokens"><TokensTab /></TabsContent>
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="members"><MembersTab /></TabsContent>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<Label className="text-xs text-muted-foreground">
Name
</Label>
<Input
type="search"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Avatar URL
</Label>
<Input
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="mt-1"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
onClick={handleProfileSave}
disabled={profileSaving || !profileName.trim()}
>
<Save className="h-3 w-3" />
{profileSaving ? "Updating..." : "Update Profile"}
</Button>
</div>
</div>
</section>
{/* API Tokens */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Key className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">API Tokens</h2>
</div>
<div className="rounded-lg border p-4 space-y-3">
<p className="text-xs text-muted-foreground">
Personal access tokens allow the CLI and external integrations to authenticate with your account.
</p>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<Input
type="text"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="Token name (e.g. My CLI)"
/>
<Select value={tokenExpiry} onValueChange={(v) => { if (v) setTokenExpiry(v); }}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
<SelectItem value="365">1 year</SelectItem>
<SelectItem value="never">No expiry</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreateToken} disabled={tokenCreating || !tokenName.trim()}>
{tokenCreating ? "Creating..." : "Create"}
</Button>
</div>
</div>
{tokens.length > 0 && (
<div className="space-y-2">
{tokens.map((t) => (
<div key={t.id} className="flex items-center gap-3 rounded-lg border px-4 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{t.name}</div>
<div className="text-xs text-muted-foreground">
{t.token_prefix}... · Created {new Date(t.created_at).toLocaleDateString()} · {t.last_used_at ? `Last used ${new Date(t.last_used_at).toLocaleDateString()}` : "Never used"}
{t.expires_at && ` · Expires ${new Date(t.expires_at).toLocaleDateString()}`}
</div>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRevokeToken(t.id)}
disabled={tokenRevoking === t.id}
aria-label={`Revoke ${t.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</section>
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Token created</DialogTitle>
<DialogDescription>
Copy your personal access token now. You won&apos;t be able to see it again.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted/50 px-3 py-2 text-sm break-all select-all">
{newToken}
</code>
<Button variant="outline" size="icon" onClick={handleCopyToken}>
{tokenCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<DialogFooter>
<Button onClick={() => { setNewToken(null); setTokenCopied(false); }}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Workspace info */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Workspace</h2>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<Label className="text-xs text-muted-foreground">
Name
</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!canManageWorkspace}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Description
</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
disabled={!canManageWorkspace}
className="mt-1 resize-none"
placeholder="What does this workspace focus on?"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Context
</Label>
<Textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={4}
disabled={!canManageWorkspace}
className="mt-1 resize-none"
placeholder="Background information and context for AI agents working in this workspace"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Slug
</Label>
<div className="mt-1 rounded-md border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
{workspace.slug}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
{!canManageWorkspace && (
<p className="text-xs text-muted-foreground">
Only admins and owners can update workspace settings.
</p>
)}
</div>
</section>
{/* Members */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">
Members ({members.length})
</h2>
</div>
</div>
{canManageWorkspace && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-2">
<Plus className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Add member</h3>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="user@company.com"
/>
<Select value={inviteRole} onValueChange={(value) => setInviteRole(value as MemberRole)}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{isOwner && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
<Button
onClick={handleAddMember}
disabled={inviteLoading || !inviteEmail.trim()}
>
{inviteLoading ? "Adding..." : "Add"}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{members.map((m) => (
<MemberRow
key={m.id}
member={m}
canManage={canManageWorkspace}
canManageOwners={isOwner}
isSelf={m.user_id === user?.id}
busy={memberActionId === m.id}
onRoleChange={(role) => handleRoleChange(m.id, role)}
onRemove={() => handleRemoveMember(m)}
/>
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground">No members found.</p>
)}
</div>
</section>
<section className="space-y-4">
<div className="flex items-center gap-2">
<LogOut className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Danger Zone</h2>
</div>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">Leave workspace</p>
<p className="text-xs text-muted-foreground">
Remove yourself from this workspace.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleLeaveWorkspace}
disabled={memberActionId === "leave"}
>
{memberActionId === "leave" ? "Leaving..." : "Leave workspace"}
</Button>
</div>
{isOwner && (
<div className="flex flex-col gap-2 border-t pt-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-destructive">Delete workspace</p>
<p className="text-xs text-muted-foreground">
Permanently delete this workspace and its data.
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteWorkspace}
disabled={memberActionId === "delete-workspace"}
>
{memberActionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
</Button>
</div>
)}
</div>
</section>
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmAction?.title}</AlertDialogTitle>
<AlertDialogDescription>{confirmAction?.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={confirmAction?.variant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""}
onClick={async () => {
await confirmAction?.onConfirm();
setConfirmAction(null);
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
</Tabs>
);
}

View file

@ -100,7 +100,7 @@
}
.dark {
--background: oklch(0.141 0.005 285.823);
--background: oklch(0.18 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);

View file

@ -0,0 +1,196 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { Bot } from "lucide-react";
import { ReactRenderer } from "@tiptap/react";
import { useWorkspaceStore } from "@/features/workspace";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent";
}
interface MentionListProps {
items: MentionItem[];
command: (item: MentionItem) => void;
}
export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
// ---------------------------------------------------------------------------
// MentionList — the popup rendered inside the editor
// ---------------------------------------------------------------------------
const MentionList = forwardRef<MentionListRef, MentionListProps>(
function MentionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const selectItem = (index: number) => {
const item = items[index];
if (item) command(item);
};
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
No results
</div>
);
}
return (
<div className="rounded-md border bg-popover py-1 shadow-md min-w-[180px] max-h-[240px] overflow-y-auto">
{items.map((item, index) => (
<button
key={`${item.type}-${item.id}`}
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm transition-colors ${
index === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={() => selectItem(index)}
>
{item.type === "agent" ? (
<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>
) : (
<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
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</span>
)}
<span className="truncate">{item.label}</span>
</button>
))}
</div>
);
},
);
// ---------------------------------------------------------------------------
// Suggestion config factory
// ---------------------------------------------------------------------------
export function createMentionSuggestion(): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
return {
items: ({ query }) => {
const { members, agents } = useWorkspaceStore.getState();
const q = query.toLowerCase();
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
id: m.user_id,
label: m.name,
type: "member" as const,
}));
const agentItems: MentionItem[] = agents
.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);
},
render: () => {
let renderer: ReactRenderer<MentionListRef> | null = null;
let popup: HTMLDivElement | null = null;
return {
onStart: (props: SuggestionProps<MentionItem>) => {
renderer = new ReactRenderer(MentionList, {
props: { items: props.items, command: props.command },
editor: props.editor,
});
popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.zIndex = "50";
popup.appendChild(renderer.element);
document.body.appendChild(popup);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<MentionItem>) => {
renderer?.updateProps({
items: props.items,
command: props.command,
});
if (popup) updatePosition(popup, props.clientRect);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
cleanup();
},
};
function updatePosition(
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) {
if (!clientRect) return;
const rect = clientRect();
if (!rect) return;
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 4}px`;
}
function cleanup() {
renderer?.destroy();
renderer = null;
popup?.remove();
popup = null;
}
},
};
}

View file

@ -130,6 +130,16 @@
text-underline-offset: 2px;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, transparent);
padding: 0 0.2em;
border-radius: calc(var(--radius) * 0.5);
font-weight: 500;
text-decoration: none;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;

View file

@ -11,9 +11,11 @@ import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import { Markdown } from "tiptap-markdown";
import { Extension } from "@tiptap/core";
import { cn } from "@/lib/utils";
import { createMentionSuggestion } from "./mention-suggestion";
import "./rich-text-editor.css";
// ---------------------------------------------------------------------------
@ -40,6 +42,54 @@ interface RichTextEditorRef {
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Mention extension configured for markdown serialization
// Stores as: [@Label](mention://type/id)
// ---------------------------------------------------------------------------
const MentionExtension = Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: createMentionSuggestion(),
}).extend({
renderHTML({ node, HTMLAttributes }) {
return [
"a",
{
...HTMLAttributes,
href: `mention://${node.attrs.type ?? "member"}/${node.attrs.id}`,
"data-mention-type": node.attrs.type ?? "member",
"data-mention-id": node.attrs.id,
},
`@${node.attrs.label ?? node.attrs.id}`,
];
},
addAttributes() {
return {
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member",
},
};
},
addStorage() {
return {
markdown: {
serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) {
state.write(
`[@${node.attrs.label ?? node.attrs.id}](mention://${node.attrs.type ?? "member"}/${node.attrs.id})`,
);
},
parse: {},
},
};
},
});
// ---------------------------------------------------------------------------
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
@ -103,6 +153,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
},
}),
Typography,
MentionExtension,
Markdown.configure({
html: false,
transformPastedText: true,

View file

@ -2,6 +2,7 @@ import * as React from 'react'
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
import { Copy, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
import { cn } from '@/lib/utils'
export interface CodeBlockProps {
@ -179,19 +180,26 @@ export function CodeBlock({
<span className="text-muted-foreground font-medium uppercase tracking-wide">
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
</span>
<Button
variant="ghost"
size="icon-xs"
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
aria-label="Copy code"
>
{copied ? (
<Check className="size-3.5 text-success" />
) : (
<Copy className="size-3.5" />
)}
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
aria-label="Copy code"
>
{copied ? (
<Check className="size-3.5 text-success" />
) : (
<Copy className="size-3.5" />
)}
</Button>
}
/>
<TooltipContent>Copy code</TooltipContent>
</Tooltip>
</div>
{/* Code content */}

View file

@ -56,8 +56,20 @@ function createComponents(
onFileClick?: (path: string) => void
): Partial<Components> {
const baseComponents: Partial<Components> = {
// Links: Make clickable with callbacks
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id or mention://agent/id
if (href?.startsWith('mention://')) {
return (
<span
className="text-primary font-medium"
style={{ background: 'color-mix(in srgb, var(--primary) 8%, transparent)', padding: '0 0.2em', borderRadius: 'calc(var(--radius) * 0.5)' }}
>
{children}
</span>
)
}
const handleClick = (e: React.MouseEvent): void => {
e.preventDefault()
if (href) {

View file

@ -17,7 +17,7 @@ function ThemeProvider({
{...props}
>
<ThemeHotkey />
<TooltipProvider>
<TooltipProvider delay={500}>
{children}
</TooltipProvider>
</NextThemesProvider>

View file

@ -17,7 +17,7 @@ function formatDate(date: string): string {
export function BoardCardContent({ issue }: { issue: Issue }) {
return (
<div className="rounded-lg border bg-background p-3">
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<PriorityIcon priority={issue.priority} />
<span>{issue.id.slice(0, 8)}</span>

View file

@ -1,6 +1,7 @@
"use client";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
@ -53,14 +54,21 @@ export function BoardColumn({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => useModalStore.getState().open("create-issue", { status })}
>
<Plus className="size-3.5" />
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => useModalStore.getState().open("create-issue", { status })}
>
<Plus className="size-3.5" />
</Button>
}
/>
<TooltipContent>Add issue</TooltipContent>
</Tooltip>
</div>
</div>
<div

View file

@ -621,19 +621,26 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={sidebarOpen ? "secondary" : "ghost"}
size="icon-xs"
className={sidebarOpen ? "" : "text-muted-foreground"}
onClick={() => {
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) panel.expand();
else panel.collapse();
}}
>
<PanelRight className="h-4 w-4" />
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant={sidebarOpen ? "secondary" : "ghost"}
size="icon-xs"
className={sidebarOpen ? "" : "text-muted-foreground"}
onClick={() => {
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) panel.expand();
else panel.collapse();
}}
>
<PanelRight className="h-4 w-4" />
</Button>
}
/>
<TooltipContent side="bottom">Toggle sidebar</TooltipContent>
</Tooltip>
</div>
{/* Delete confirmation dialog (controlled by state) */}
@ -745,22 +752,36 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
</Tooltip>
{isOwn && (
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon-xs"
onClick={() => startEditComment(comment)}
className="text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDeleteComment(comment.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={() => startEditComment(comment)}
className="text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</Button>
}
/>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDeleteComment(comment.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
}
/>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
)}
</div>
@ -797,13 +818,20 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
/>
</div>
<div className="flex items-center justify-end px-2 pb-2">
<Button
size="icon-xs"
disabled={commentEmpty || submitting}
onClick={handleSubmitComment}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
size="icon-sm"
disabled={commentEmpty || submitting}
onClick={handleSubmitComment}
>
<ArrowUp className="h-4 w-4" />
</Button>
}
/>
<TooltipContent>Send</TooltipContent>
</Tooltip>
</div>
</div>
</div>

View file

@ -46,6 +46,30 @@ export function IssuesHeader() {
return (
<div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-2">
{/* View toggle */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
{viewMode === "board" ? <Columns3 /> : <List />}
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>View</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setViewMode("board")}>
<Columns3 />
Board
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setViewMode("list")}>
<List />
List
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Status filter */}
<DropdownMenu>
<DropdownMenuTrigger
@ -122,30 +146,6 @@ export function IssuesHeader() {
</div>
<div className="flex items-center gap-2">
{/* View toggle */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
{viewMode === "board" ? <Columns3 /> : <List />}
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>View</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setViewMode("board")}>
<Columns3 />
Board
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setViewMode("list")}>
<List />
List
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* New issue */}
<Button
variant="outline"

View file

@ -22,6 +22,7 @@ import {
PopoverContent,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
@ -138,18 +139,32 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
<span className="font-medium">New issue</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<X className="size-4" />
</button>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
/>
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<X className="size-4" />
</button>
}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
@ -167,7 +182,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
}
}}
placeholder="Issue title"
className="border-none shadow-none px-0 text-lg font-semibold focus-visible:ring-0"
className="border-none shadow-none px-0 text-lg font-semibold focus-visible:ring-0 dark:bg-transparent"
/>
</div>

View file

@ -26,6 +26,7 @@ import {
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@ -210,24 +211,38 @@ function FileEditor({
placeholder="path/to/file.md"
className="h-7 border-0 p-0 text-xs font-mono shadow-none focus-visible:ring-0"
/>
<Button
variant="ghost"
size="icon-xs"
onClick={() =>
setEditingIndex(editingIndex === index ? null : index)
}
className="shrink-0 text-muted-foreground"
>
<FileText className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeFile(index)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={() =>
setEditingIndex(editingIndex === index ? null : index)
}
className="shrink-0 text-muted-foreground"
>
<FileText className="h-3 w-3" />
</Button>
}
/>
<TooltipContent>Edit content</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeFile(index)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
}
/>
<TooltipContent>Remove file</TooltipContent>
</Tooltip>
</div>
{editingIndex === index && (
<Textarea
@ -325,14 +340,21 @@ function SkillDetail({
{saving ? "Saving..." : "Save"}
</Button>
)}
<Button
variant="ghost"
size="xs"
onClick={() => setConfirmDelete(true)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="xs"
onClick={() => setConfirmDelete(true)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
}
/>
<TooltipContent>Delete skill</TooltipContent>
</Tooltip>
</div>
</div>
@ -489,13 +511,20 @@ export default function SkillsPage() {
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Skills</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
}
/>
<TooltipContent side="bottom">Create skill</TooltipContent>
</Tooltip>
</div>
{skills.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">

View file

@ -17,6 +17,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/extension-link": "^3.20.5",
"@tiptap/extension-mention": "^3.20.5",
"@tiptap/extension-placeholder": "^3.20.5",
"@tiptap/extension-typography": "^3.20.5",
"@tiptap/pm": "^3.20.5",

27
pnpm-lock.yaml generated
View file

@ -72,6 +72,9 @@ importers:
'@tiptap/extension-link':
specifier: ^3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/extension-mention':
specifier: ^3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@tiptap/suggestion@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
'@tiptap/extension-placeholder':
specifier: ^3.20.5
version: 3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
@ -1380,6 +1383,13 @@ packages:
'@tiptap/core': ^3.20.5
'@tiptap/pm': ^3.20.5
'@tiptap/extension-mention@3.20.5':
resolution: {integrity: sha512-SEyIV500gAfzylvbWog2gUK6Z6fJhGYXCuGOHAGj+w2Vy3C262w8HXC9uQ+BrY/vdZp8iSpFY4AbTf5xkqkijA==}
peerDependencies:
'@tiptap/core': ^3.20.5
'@tiptap/pm': ^3.20.5
'@tiptap/suggestion': ^3.20.5
'@tiptap/extension-ordered-list@3.20.5':
resolution: {integrity: sha512-Y/RIE3AxUNYAFKGMM5FLlTVKxxBvOh4JlLp/qYsOCY2nJdH0Jopl2FpfBYc4xoJwFSk8BELJ4Ow0adcYb15ksg==}
peerDependencies:
@ -1437,6 +1447,12 @@ packages:
'@tiptap/starter-kit@3.20.5':
resolution: {integrity: sha512-L5E2TCGK0EiwmGIlwMsiwNTW1TLbfPF1Dsji4bSKRJnPbccZIMCB6qdId8v/Z+QGm85NVcBHeruQrDlKDddXBA==}
'@tiptap/suggestion@3.20.5':
resolution: {integrity: sha512-5fqRNgnzYdJ1oDpyLqwrbVsZwvI+5VW/U89LPMvBYM7sFS7Xd0xfyxyAOWcJN4V0zLeTcuElWN3R+IUTLKbU+Q==}
peerDependencies:
'@tiptap/core': ^3.20.5
'@tiptap/pm': ^3.20.5
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@ -4945,6 +4961,12 @@ snapshots:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
'@tiptap/extension-mention@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@tiptap/suggestion@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
'@tiptap/suggestion': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/extension-ordered-list@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
@ -5043,6 +5065,11 @@ snapshots:
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
'@tiptap/suggestion@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3

View file

@ -3,6 +3,7 @@ package main
import (
"context"
"log/slog"
"regexp"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/handler"
@ -11,11 +12,83 @@ import (
"github.com/multica-ai/multica/server/pkg/protocol"
)
// mention represents a parsed @mention from markdown content.
type mention struct {
Type string // "member" or "agent"
ID string // user_id or agent_id
}
// mentionRe matches [@Label](mention://type/id) in markdown.
var mentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`)
// parseMentions extracts mentions from markdown content.
func parseMentions(content string) []mention {
matches := mentionRe.FindAllStringSubmatch(content, -1)
seen := make(map[string]bool)
var result []mention
for _, m := range matches {
key := m[1] + ":" + m[2]
if seen[key] {
continue
}
seen[key] = true
result = append(result, mention{Type: m[1], ID: m[2]})
}
return result
}
// notifyMentionedMembers creates inbox items for each @mentioned member,
// excluding the actor and any IDs in the skip set.
func notifyMentionedMembers(
bus *events.Bus,
queries *db.Queries,
e events.Event,
mentions []mention,
issueID string,
issueTitle string,
issueStatus string,
title string,
skip map[string]bool,
) {
for _, m := range mentions {
if m.Type != "member" {
continue
}
if m.ID == e.ActorID || skip[m.ID] {
continue
}
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
WorkspaceID: parseUUID(e.WorkspaceID),
RecipientType: "member",
RecipientID: parseUUID(m.ID),
Type: "mentioned",
Severity: "info",
IssueID: parseUUID(issueID),
Title: title,
ActorType: util.StrToText(e.ActorType),
ActorID: parseUUID(e.ActorID),
})
if err != nil {
slog.Error("mention inbox creation failed", "mentioned_id", m.ID, "error", err)
continue
}
resp := inboxItemToResponse(item)
resp["issue_status"] = issueStatus
bus.Publish(events.Event{
Type: protocol.EventInboxNew,
WorkspaceID: e.WorkspaceID,
ActorType: e.ActorType,
ActorID: e.ActorID,
Payload: map[string]any{"item": resp},
})
}
}
// registerInboxListeners wires up event bus listeners that create inbox
// notifications. This replaces the inline CreateInboxItem calls that were
// previously scattered across issue and comment handlers.
func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
// issue:created — notify assignee about new assignment
// issue:created — notify assignee about new assignment + @mentions in description
bus.Subscribe(protocol.EventIssueCreated, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
@ -25,37 +98,46 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
if !ok {
return
}
if issue.AssigneeType == nil || issue.AssigneeID == nil {
return
// Track who already got notified to avoid duplicates
skip := map[string]bool{e.ActorID: true}
// Notify assignee
if issue.AssigneeType != nil && issue.AssigneeID != nil {
skip[*issue.AssigneeID] = true
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
WorkspaceID: parseUUID(issue.WorkspaceID),
RecipientType: *issue.AssigneeType,
RecipientID: parseUUID(*issue.AssigneeID),
Type: "issue_assigned",
Severity: "action_required",
IssueID: parseUUID(issue.ID),
Title: "New issue assigned: " + issue.Title,
Body: util.PtrToText(issue.Description),
ActorType: util.StrToText(e.ActorType),
ActorID: parseUUID(e.ActorID),
})
if err != nil {
slog.Error("inbox item creation failed", "event", "issue:created", "error", err)
} else {
resp := inboxItemToResponse(item)
resp["issue_status"] = issue.Status
bus.Publish(events.Event{
Type: protocol.EventInboxNew,
WorkspaceID: e.WorkspaceID,
ActorType: e.ActorType,
ActorID: e.ActorID,
Payload: map[string]any{"item": resp},
})
}
}
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
WorkspaceID: parseUUID(issue.WorkspaceID),
RecipientType: *issue.AssigneeType,
RecipientID: parseUUID(*issue.AssigneeID),
Type: "issue_assigned",
Severity: "action_required",
IssueID: parseUUID(issue.ID),
Title: "New issue assigned: " + issue.Title,
Body: util.PtrToText(issue.Description),
ActorType: util.StrToText(e.ActorType),
ActorID: parseUUID(e.ActorID),
})
if err != nil {
slog.Error("inbox item creation failed", "event", "issue:created", "error", err)
return
// Notify @mentions in description
if issue.Description != nil && *issue.Description != "" {
mentions := parseMentions(*issue.Description)
notifyMentionedMembers(bus, queries, e, mentions, issue.ID, issue.Title, issue.Status,
"Mentioned in: "+issue.Title, skip)
}
resp := inboxItemToResponse(item)
resp["issue_status"] = issue.Status
bus.Publish(events.Event{
Type: protocol.EventInboxNew,
WorkspaceID: e.WorkspaceID,
ActorType: e.ActorType,
ActorID: e.ActorID,
Payload: map[string]any{"item": resp},
})
})
// issue:updated — notify on assignee change and status change
@ -70,8 +152,10 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
}
assigneeChanged, _ := payload["assignee_changed"].(bool)
statusChanged, _ := payload["status_changed"].(bool)
descriptionChanged, _ := payload["description_changed"].(bool)
prevAssigneeType, _ := payload["prev_assignee_type"].(*string)
prevAssigneeID, _ := payload["prev_assignee_id"].(*string)
prevDescription, _ := payload["prev_description"].(*string)
creatorType, _ := payload["creator_type"].(string)
creatorID, _ := payload["creator_id"].(string)
@ -189,9 +273,33 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
}
}
}
// Notify NEW @mentions in description (only mentions that weren't in previous description)
if descriptionChanged && issue.Description != nil {
newMentions := parseMentions(*issue.Description)
if len(newMentions) > 0 {
// Build set of previously mentioned IDs
prevMentioned := map[string]bool{}
if prevDescription != nil {
for _, m := range parseMentions(*prevDescription) {
prevMentioned[m.Type+":"+m.ID] = true
}
}
// Filter to only new mentions
var added []mention
for _, m := range newMentions {
if !prevMentioned[m.Type+":"+m.ID] {
added = append(added, m)
}
}
skip := map[string]bool{actorID: true}
notifyMentionedMembers(bus, queries, e, added, issue.ID, issue.Title, issue.Status,
"Mentioned in: "+issue.Title, skip)
}
}
})
// comment:created — notify issue assignee about new comment
// comment:created — notify issue assignee + @mentions in comment
bus.Subscribe(protocol.EventCommentCreated, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
@ -206,41 +314,44 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
issueAssigneeID, _ := payload["issue_assignee_id"].(*string)
issueStatus, _ := payload["issue_status"].(string)
// Only notify if assignee is a member and is not the commenter
if issueAssigneeType == nil || issueAssigneeID == nil {
return
}
if *issueAssigneeType != "member" || *issueAssigneeID == e.ActorID {
return
// Track who already got notified
skip := map[string]bool{e.ActorID: true}
// Notify assignee (if member and not the commenter)
if issueAssigneeType != nil && issueAssigneeID != nil &&
*issueAssigneeType == "member" && *issueAssigneeID != e.ActorID {
skip[*issueAssigneeID] = true
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
WorkspaceID: parseUUID(e.WorkspaceID),
RecipientType: "member",
RecipientID: parseUUID(*issueAssigneeID),
Type: "mentioned",
Severity: "info",
IssueID: parseUUID(comment.IssueID),
Title: "New comment on: " + issueTitle,
Body: util.StrToText(comment.Content),
ActorType: util.StrToText(e.ActorType),
ActorID: parseUUID(e.ActorID),
})
if err != nil {
slog.Error("inbox item creation failed", "event", "comment:created", "error", err)
} else {
commentResp := inboxItemToResponse(item)
commentResp["issue_status"] = issueStatus
bus.Publish(events.Event{
Type: protocol.EventInboxNew,
WorkspaceID: e.WorkspaceID,
ActorType: e.ActorType,
ActorID: e.ActorID,
Payload: map[string]any{"item": commentResp},
})
}
}
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
WorkspaceID: parseUUID(e.WorkspaceID),
RecipientType: "member",
RecipientID: parseUUID(*issueAssigneeID),
Type: "mentioned",
Severity: "info",
IssueID: parseUUID(comment.IssueID),
Title: "New comment on: " + issueTitle,
Body: util.StrToText(comment.Content),
ActorType: util.StrToText(e.ActorType),
ActorID: parseUUID(e.ActorID),
})
if err != nil {
slog.Error("inbox item creation failed", "event", "comment:created", "error", err)
return
}
commentResp := inboxItemToResponse(item)
commentResp["issue_status"] = issueStatus
bus.Publish(events.Event{
Type: protocol.EventInboxNew,
WorkspaceID: e.WorkspaceID,
ActorType: e.ActorType,
ActorID: e.ActorID,
Payload: map[string]any{"item": commentResp},
})
// Notify @mentions in comment content
mentions := parseMentions(comment.Content)
notifyMentionedMembers(bus, queries, e, mentions, comment.IssueID, issueTitle, issueStatus,
"Mentioned in comment: "+issueTitle, skip)
})
}

View file

@ -28,6 +28,8 @@ func registerListeners(bus *events.Bus, hub *realtime.Hub) {
protocol.EventInboxNew,
protocol.EventInboxRead,
protocol.EventInboxArchived,
protocol.EventInboxBatchRead,
protocol.EventInboxBatchArchived,
protocol.EventWorkspaceUpdated,
protocol.EventWorkspaceDeleted,
protocol.EventMemberAdded,

View file

@ -1,11 +1,13 @@
package handler
import (
"context"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
@ -68,6 +70,18 @@ func inboxRowToResponse(r db.ListInboxItemsRow) InboxItemResponse {
}
}
func (h *Handler) enrichInboxResponse(ctx context.Context, resp InboxItemResponse, issueID pgtype.UUID) InboxItemResponse {
if !issueID.Valid {
return resp
}
issue, err := h.Queries.GetIssue(ctx, issueID)
if err == nil {
s := issue.Status
resp.IssueStatus = &s
}
return resp
}
func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@ -124,7 +138,8 @@ func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
"recipient_id": uuidToString(item.RecipientID),
})
writeJSON(w, http.StatusOK, inboxToResponse(item))
resp := h.enrichInboxResponse(r.Context(), inboxToResponse(item), item.IssueID)
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
@ -145,7 +160,8 @@ func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
"recipient_id": uuidToString(item.RecipientID),
})
writeJSON(w, http.StatusOK, inboxToResponse(item))
resp := h.enrichInboxResponse(r.Context(), inboxToResponse(item), item.IssueID)
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {

View file

@ -375,16 +375,19 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) &&
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
statusChanged := req.Status != nil && prevIssue.Status != issue.Status
descriptionChanged := req.Description != nil && textToPtr(prevIssue.Description) != resp.Description
h.publish(protocol.EventIssueUpdated, workspaceID, "member", userID, map[string]any{
"issue": resp,
"assignee_changed": assigneeChanged,
"status_changed": statusChanged,
"prev_assignee_type": textToPtr(prevIssue.AssigneeType),
"prev_assignee_id": uuidToPtr(prevIssue.AssigneeID),
"prev_status": prevIssue.Status,
"creator_type": prevIssue.CreatorType,
"creator_id": uuidToString(prevIssue.CreatorID),
"issue": resp,
"assignee_changed": assigneeChanged,
"status_changed": statusChanged,
"description_changed": descriptionChanged,
"prev_assignee_type": textToPtr(prevIssue.AssigneeType),
"prev_assignee_id": uuidToPtr(prevIssue.AssigneeID),
"prev_status": prevIssue.Status,
"prev_description": textToPtr(prevIssue.Description),
"creator_type": prevIssue.CreatorType,
"creator_id": uuidToString(prevIssue.CreatorID),
})
// If assignee or readiness status changed, reconcile the task queue.

View file

@ -197,7 +197,7 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
}
if issueErr == nil {
s.createInboxForIssueCreator(ctx, issue, "review_requested", "attention", "Review requested: "+issue.Title, "")
s.createInboxForIssueCreator(ctx, issue, task.AgentID, "review_requested", "attention", "Review requested: "+issue.Title, "")
}
// Reconcile agent status
@ -233,7 +233,7 @@ func (s *TaskService) FailTask(ctx context.Context, taskID pgtype.UUID, errMsg s
s.createAgentComment(ctx, task.IssueID, task.AgentID, errMsg, "system")
}
if issueErr == nil {
s.createInboxForIssueCreator(ctx, issue, "agent_blocked", "action_required", "Agent blocked: "+issue.Title, errMsg)
s.createInboxForIssueCreator(ctx, issue, task.AgentID, "agent_blocked", "action_required", "Agent blocked: "+issue.Title, errMsg)
}
// Reconcile agent status
@ -474,7 +474,7 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p
})
}
func (s *TaskService) createInboxForIssueCreator(ctx context.Context, issue db.Issue, itemType, severity, title, body string) {
func (s *TaskService) createInboxForIssueCreator(ctx context.Context, issue db.Issue, agentID pgtype.UUID, itemType, severity, title, body string) {
if issue.CreatorType != "member" {
return
}
@ -487,16 +487,20 @@ func (s *TaskService) createInboxForIssueCreator(ctx context.Context, issue db.I
IssueID: issue.ID,
Title: title,
Body: util.PtrToText(&body),
ActorType: util.StrToText("agent"),
ActorID: agentID,
})
if err != nil {
return
}
resp := inboxToMap(item)
resp["issue_status"] = issue.Status
s.Bus.Publish(events.Event{
Type: protocol.EventInboxNew,
WorkspaceID: util.UUIDToString(issue.WorkspaceID),
ActorType: "system",
ActorID: "",
Payload: map[string]any{"item": inboxToMap(item)},
ActorType: "agent",
ActorID: util.UUIDToString(agentID),
Payload: map[string]any{"item": resp},
})
}
@ -552,6 +556,8 @@ func inboxToMap(item db.InboxItem) map[string]any {
"read": item.Read,
"archived": item.Archived,
"created_at": util.TimestampToString(item.CreatedAt),
"actor_type": util.TextToPtr(item.ActorType),
"actor_id": util.UUIDToPtr(item.ActorID),
}
}