Merge pull request #255 from multica-ai/NevilleQingNY/ui-ux-fixes
feat(ui): UI/UX polish — layout, sidebar, button fixes
This commit is contained in:
commit
ade113975d
24 changed files with 255 additions and 210 deletions
|
|
@ -13,8 +13,10 @@ import {
|
|||
Plus,
|
||||
Check,
|
||||
Sparkles,
|
||||
Search,
|
||||
SquarePen,
|
||||
} from "lucide-react";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
|
@ -35,6 +37,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
|
|
@ -68,24 +71,24 @@ export function AppSidebar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar variant="inset">
|
||||
{/* Workspace Switcher */}
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg">
|
||||
<MulticaIcon className="size-4" noSpin />
|
||||
<span className="flex-1 truncate font-semibold">
|
||||
{workspace?.name ?? "Multica"}
|
||||
</span>
|
||||
<ChevronDown className="size-4" />
|
||||
</SidebarMenuButton>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<SidebarMenu className="min-w-0 flex-1">
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton>
|
||||
<WorkspaceAvatar name={workspace?.name ?? "M"} size="sm" />
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{workspace?.name ?? "Multica"}
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
</SidebarMenuButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
className="w-52"
|
||||
align="start"
|
||||
|
|
@ -99,8 +102,28 @@ export function AppSidebar() {
|
|||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
<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
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="ml-auto opacity-0 group-hover/ws-section:opacity-100 transition-opacity rounded hover:bg-accent p-0.5"
|
||||
onClick={() => useModalStore.getState().open("create-workspace")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Create workspace
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DropdownMenuLabel>
|
||||
{workspaces.map((ws) => (
|
||||
<DropdownMenuItem
|
||||
|
|
@ -111,46 +134,52 @@ export function AppSidebar() {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-[10px] font-semibold">
|
||||
{ws.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<WorkspaceAvatar name={ws.name} size="sm" />
|
||||
<span className="flex-1 truncate">{ws.name}</span>
|
||||
{ws.id === workspace?.id && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={() => useModalStore.getState().open("create-workspace")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create workspace
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
render={<Link href="/settings" />}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={logout}>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Sign out
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Search</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
|
||||
onClick={() => useModalStore.getState().open("create-issue")}
|
||||
>
|
||||
<SquarePen className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
{/* Navigation */}
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
|
|
@ -160,6 +189,7 @@ export function AppSidebar() {
|
|||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
render={<Link href={item.href} />}
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
|
|
@ -198,6 +228,5 @@ export function AppSidebar() {
|
|||
)}
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1002,7 +1002,7 @@ function AgentDetail({
|
|||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-base font-semibold">{agent.name}</h2>
|
||||
<h2 className="text-base font-semibold truncate">{agent.name}</h2>
|
||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
|
|
@ -1017,7 +1017,7 @@ function AgentDetail({
|
|||
</span>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{agent.description}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
|
|
@ -1091,15 +1091,15 @@ function AgentDetail({
|
|||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Delete agent?</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">Delete agent?</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
This will permanently delete "{agent.name}" and all its configuration.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
|
|
@ -1184,14 +1184,14 @@ export default function AgentsPage() {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left column — agent list */}
|
||||
<div className="w-72 shrink-0 overflow-y-auto border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ function InboxDetail({
|
|||
<div className="flex items-start gap-3">
|
||||
<Icon className={`mt-1 h-5 w-5 shrink-0 ${colorClass}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold">{item.title}</h2>
|
||||
<h2 className="text-lg font-semibold truncate">{item.title}</h2>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className={colorClass}>{severityLabel[item.severity]}</span>
|
||||
<span>·</span>
|
||||
|
|
@ -223,7 +223,7 @@ export default function InboxPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-80 shrink-0 border-r">
|
||||
<div className="flex h-12 items-center border-b px-4">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
|
|
@ -250,7 +250,7 @@ export default function InboxPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left column — inbox list */}
|
||||
<div className="w-80 shrink-0 overflow-y-auto border-r">
|
||||
<div className="flex h-12 items-center border-b px-4">
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import {
|
|||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
|
||||
|
|
@ -204,6 +203,7 @@ function AcceptanceCriteriaEditor({
|
|||
onChange={(e) => setNewItem(e.target.value)}
|
||||
onBlur={() => { if (!newItem.trim()) setAdding(false); }}
|
||||
placeholder="Add criteria..."
|
||||
aria-label="Add acceptance criteria"
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
|
|
@ -286,6 +286,7 @@ function ContextRefsEditor({
|
|||
onChange={(e) => setNewRef(e.target.value)}
|
||||
onBlur={() => { if (!newRef.trim()) setAdding(false); }}
|
||||
placeholder="Add reference URL..."
|
||||
aria-label="Add context reference URL"
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
|
|
@ -467,7 +468,7 @@ export default function IssueDetailPage({
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
|
|
@ -475,14 +476,14 @@ export default function IssueDetailPage({
|
|||
|
||||
if (!issue) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Issue not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* LEFT: Content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header bar */}
|
||||
|
|
@ -617,20 +618,18 @@ export default function IssueDetailPage({
|
|||
<span className="text-[13px] font-medium">
|
||||
{getActorName(comment.author_type, comment.author_id)}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="text-[12px] text-muted-foreground cursor-default">
|
||||
{timeAgo(comment.created_at)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
{new Date(comment.created_at).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="text-[12px] text-muted-foreground cursor-default">
|
||||
{timeAgo(comment.created_at)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
{new Date(comment.created_at).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOwn && (
|
||||
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
|
|
@ -658,6 +657,7 @@ export default function IssueDetailPage({
|
|||
autoFocus
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
aria-label="Edit comment"
|
||||
className="w-full text-[13px] bg-transparent border-b outline-none"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ function BoardView({
|
|||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex h-full gap-3 overflow-x-auto p-4">
|
||||
<div className="flex flex-1 min-h-0 gap-3 overflow-x-auto p-4">
|
||||
{visibleStatuses.map((status) => (
|
||||
<DroppableColumn
|
||||
key={status}
|
||||
|
|
@ -360,7 +360,7 @@ export default function IssuesPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
|
|
@ -379,7 +379,7 @@ export default function IssuesPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@ export default function KnowledgeBasePage() {
|
|||
const selected = documents.find((d) => d.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left: Document list */}
|
||||
<div className="w-72 shrink-0 overflow-y-auto border-r">
|
||||
<div className="flex h-11 items-center justify-between border-b px-4">
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export default function DashboardLayout({
|
|||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="overflow-hidden">{children}</SidebarInset>
|
||||
<SidebarInset>{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ function MemberRow({
|
|||
.slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{member.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{member.email}</div>
|
||||
<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}>
|
||||
|
|
@ -274,6 +274,7 @@ export default function SettingsPage() {
|
|||
if (!workspace) return null;
|
||||
|
||||
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">
|
||||
|
|
@ -523,5 +524,6 @@ export default function SettingsPage() {
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
animation: entrance-spin 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Shadcn sidebar: remove default padding from inset container */
|
||||
[data-slot="sidebar-container"] {
|
||||
padding: 0 !important;
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
color: var(--sidebar-accent-foreground);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent: oklch(0.95 0.002 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ export default function RootLayout({
|
|||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans", geist.variable, geistMono.variable)}
|
||||
className={cn("antialiased font-sans h-full overflow-hidden", geist.variable, geistMono.variable)}
|
||||
>
|
||||
<body>
|
||||
<body className="h-full overflow-hidden">
|
||||
<ThemeProvider>
|
||||
<AuthInitializer>
|
||||
<WSProvider>{children}</WSProvider>
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ function LocalDaemonPairPageContent() {
|
|||
<Label className="mb-2">Workspace</Label>
|
||||
<Select value={selectedWorkspaceId} onValueChange={(v) => setSelectedWorkspaceId(v ?? "")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<span className="flex flex-1 text-left">
|
||||
<span className="flex flex-1 min-w-0 truncate text-left">
|
||||
{selectedWorkspace?.name ?? "Select workspace"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,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 { cn } from '@/lib/utils'
|
||||
|
||||
export interface CodeBlockProps {
|
||||
|
|
@ -177,36 +179,19 @@ export function CodeBlock({
|
|||
<span className="text-muted-foreground font-medium uppercase tracking-wide">
|
||||
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
|
||||
</span>
|
||||
<button
|
||||
<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 ? (
|
||||
<svg
|
||||
className="w-4 h-4 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<Check className="size-3.5 text-success" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
function ThemeProvider({
|
||||
children,
|
||||
|
|
@ -16,7 +17,9 @@ function ThemeProvider({
|
|||
{...props}
|
||||
>
|
||||
<ThemeHotkey />
|
||||
{children}
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,15 +57,14 @@ export function AssigneePicker({
|
|||
assigneeType && assigneeId ? (
|
||||
<>
|
||||
<div
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] ${
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-[18px] ${
|
||||
assigneeType === "agent"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
|
||||
? "bg-info/10 text-info"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
style={{ width: 18, height: 18 }}
|
||||
>
|
||||
{assigneeType === "agent" ? (
|
||||
<Bot style={{ width: 10, height: 10 }} />
|
||||
<Bot className="size-2.5" />
|
||||
) : (
|
||||
getActorInitials(assigneeType, assigneeId)
|
||||
)}
|
||||
|
|
@ -128,8 +127,8 @@ export function AssigneePicker({
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300">
|
||||
<Bot style={{ width: 10, height: 10 }} />
|
||||
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span>{a.name}</span>
|
||||
</PickerItem>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export function PropertyPicker({
|
|||
onSearchChange?.(e.target.value);
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label="Filter options"
|
||||
className="w-full bg-transparent text-[13px] placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -60,6 +61,7 @@ export function CreateIssueModal({ onClose }: { onClose: () => void }) {
|
|||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Issue</DialogTitle>
|
||||
<DialogDescription className="sr-only">Create a new issue for the workspace.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -2,24 +2,33 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const slugError =
|
||||
slug.length > 0 && !SLUG_REGEX.test(slug)
|
||||
? "Only lowercase letters, numbers, and hyphens"
|
||||
: null;
|
||||
|
||||
const canSubmit = name.trim().length > 0 && slug.trim().length > 0 && !slugError;
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
setSlug(
|
||||
|
|
@ -31,7 +40,7 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
|||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim() || !slug.trim()) return;
|
||||
if (!canSubmit) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const { createWorkspace, switchWorkspace } =
|
||||
|
|
@ -51,47 +60,73 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
|||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new workspace for your team.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
className="mt-1"
|
||||
/>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="inset-0 flex h-full w-full max-w-none sm:max-w-none translate-0 flex-col items-center justify-center rounded-none bg-background ring-0 shadow-none"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-6 left-6 text-muted-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6">
|
||||
<div className="text-center">
|
||||
<DialogTitle className="text-2xl font-semibold">
|
||||
Create a new workspace
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-2">
|
||||
Workspaces are shared environments where teams can work on
|
||||
projects and issues.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Slug</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="my-workspace"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Workspace Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Workspace URL</Label>
|
||||
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
|
||||
<span className="pl-3 text-sm text-muted-foreground select-none">
|
||||
multica.app/
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="my-workspace"
|
||||
className="border-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
{slugError && (
|
||||
<p className="text-xs text-destructive">{slugError}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !name.trim() || !slug.trim()}
|
||||
disabled={creating || !canSubmit}
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
{creating ? "Creating..." : "Create workspace"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -298,17 +298,17 @@ function SkillDetail({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{skill.name}</h2>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold truncate">{skill.name}</h2>
|
||||
{skill.description && (
|
||||
<p className="text-xs text-muted-foreground">{skill.description}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -380,15 +380,15 @@ function SkillDetail({
|
|||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Delete skill?</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">Delete skill?</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
This will permanently delete "{skill.name}" and remove it from all agents.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
|
|
@ -469,7 +469,7 @@ export default function SkillsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left column — skill list */}
|
||||
<div className="w-72 shrink-0 overflow-y-auto border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
|
|
|
|||
29
apps/web/features/workspace/components/workspace-avatar.tsx
Normal file
29
apps/web/features/workspace/components/workspace-avatar.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
const sizeMap = {
|
||||
sm: "h-5 w-5 text-[10px] rounded",
|
||||
md: "h-7 w-7 text-xs rounded-md",
|
||||
lg: "h-9 w-9 text-sm rounded-md",
|
||||
} as const;
|
||||
|
||||
interface WorkspaceAvatarProps {
|
||||
name: string;
|
||||
size?: keyof typeof sizeMap;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function WorkspaceAvatar({ name, size = "sm", className }: WorkspaceAvatarProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center border bg-muted font-semibold text-muted-foreground",
|
||||
sizeMap[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkspaceAvatar, type WorkspaceAvatarProps };
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export { useWorkspaceStore } from "./store";
|
||||
export { useActorName } from "./hooks";
|
||||
export { WorkspaceAvatar } from "./components/workspace-avatar";
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
|
|
|
|||
|
|
@ -107,11 +107,6 @@
|
|||
@apply w-full max-w-4xl mx-auto;
|
||||
}
|
||||
|
||||
/* Sidebar: remove default padding from inset container */
|
||||
[data-slot="sidebar-container"] {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Tool status: running glow pulse */
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--tool-running); }
|
||||
|
|
|
|||
45
pnpm-lock.yaml
generated
45
pnpm-lock.yaml
generated
|
|
@ -113,7 +113,7 @@ importers:
|
|||
version: 1.0.1(react@19.2.3)
|
||||
next:
|
||||
specifier: ^16.1.6
|
||||
version: 16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 16.2.0(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
|
@ -197,28 +197,6 @@ importers:
|
|||
specifier: ^4.1.0
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
packages/hooks:
|
||||
dependencies:
|
||||
'@multica/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
'@multica/store':
|
||||
specifier: workspace:*
|
||||
version: link:../store
|
||||
'@multica/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.14
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/sdk:
|
||||
dependencies:
|
||||
'@multica/types':
|
||||
|
|
@ -229,19 +207,6 @@ importers:
|
|||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/store:
|
||||
dependencies:
|
||||
'@multica/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
zustand:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/types:
|
||||
devDependencies:
|
||||
typescript:
|
||||
|
|
@ -6163,7 +6128,7 @@ snapshots:
|
|||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next@16.2.0(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@next/env': 16.2.0
|
||||
'@swc/helpers': 0.5.15
|
||||
|
|
@ -6172,7 +6137,7 @@ snapshots:
|
|||
postcss: 8.4.31
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3)
|
||||
styled-jsx: 5.1.6(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.2.0
|
||||
'@next/swc-darwin-x64': 16.2.0
|
||||
|
|
@ -6838,12 +6803,10 @@ snapshots:
|
|||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3):
|
||||
styled-jsx@5.1.6(react@19.2.3):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.3
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue