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:
parent
a3d20d3644
commit
4052017c7a
27 changed files with 1619 additions and 878 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
272
apps/web/app/(dashboard)/settings/_components/members-tab.tsx
Normal file
272
apps/web/app/(dashboard)/settings/_components/members-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx
Normal file
185
apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
248
apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx
Normal file
248
apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
196
apps/web/components/common/mention-suggestion.tsx
Normal file
196
apps/web/components/common/mention-suggestion.tsx
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ function ThemeProvider({
|
|||
{...props}
|
||||
>
|
||||
<ThemeHotkey />
|
||||
<TooltipProvider>
|
||||
<TooltipProvider delay={500}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</NextThemesProvider>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
27
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue