Compare commits

..

5 Commits

Author SHA1 Message Date
ZhenYi
ed6b010ecc exchange rum accesskey 2026-05-15 13:38:01 +08:00
ZhenYi
9b351e612c feat(ui): improve header layout and add theme preset selector
- Update Header with better user menu and navigation layout
- Add ThemePresetSelector component for theme customization
- Refine ChannelSidebar with improved visual hierarchy
2026-05-15 13:11:07 +08:00
ZhenYi
0491b668c7 feat(project): add member invitation to project creation modal
- Add email invite flow with role selection to ProjectCreateMenuModal
- Update MembersSettings with avatar URL support
- Adjust ProjectSettingsLayout layout for new content
2026-05-15 13:11:01 +08:00
ZhenYi
7ec848470e feat(ui): add global search palette with Cmd+K shortcut
- Add GlobalSearchPalette component for global search overlay
- Introduce global search event system for cross-component communication
- Add project permissions library for access control checks
2026-05-15 13:10:56 +08:00
ZhenYi
f597da6e33 feat(ui): improve streaming reasoning block lifecycle control
- Add autoLifecycle prop to Reasoning component for fine-grained control
- Extract StreamingReasoningBlock with manual/auto collapse state management
- Fix active thinking index logic for streaming messages
2026-05-15 12:39:10 +08:00
13 changed files with 504 additions and 28 deletions

View File

@ -342,7 +342,10 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
} }
}, [displayParts.length]); }, [displayParts.length]);
const firstThinkingIdx = displayParts.findIndex((p) => p.type === "thinking"); const activeThinkingIdx =
!displayDone && displayParts[displayParts.length - 1]?.type === "thinking"
? displayParts.length - 1
: -1;
return ( return (
<div ref={contentRef} className="flex gap-4 px-4 py-3 max-w-3xl mx-auto w-full"> <div ref={contentRef} className="flex gap-4 px-4 py-3 max-w-3xl mx-auto w-full">
@ -364,12 +367,12 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
<div className="text-sm" style={{ color: "var(--text-primary)" }}> <div className="text-sm" style={{ color: "var(--text-primary)" }}>
{displayParts.map((part, i) => { {displayParts.map((part, i) => {
if (part.type === "thinking") { if (part.type === "thinking") {
const isActivelyThinking = !displayDone && i === firstThinkingIdx;
return ( return (
<Reasoning key={i} isStreaming={isActivelyThinking} defaultOpen={isActivelyThinking}> <StreamingReasoningBlock
<ReasoningTrigger /> key={i}
<ReasoningContent>{part.content}</ReasoningContent> content={part.content}
</Reasoning> isActivelyThinking={i === activeThinkingIdx}
/>
); );
} }
if (part.type === "tool_call") { if (part.type === "tool_call") {
@ -407,6 +410,59 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
); );
} }
function StreamingReasoningBlock({
content,
isActivelyThinking,
}: {
content: string;
isActivelyThinking: boolean;
}) {
const [manualOpen, setManualOpen] = useState(false);
const [autoCollapsed, setAutoCollapsed] = useState(false);
const wasActivelyThinkingRef = useRef(isActivelyThinking);
useEffect(() => {
const wasActivelyThinking = wasActivelyThinkingRef.current;
if (isActivelyThinking && !wasActivelyThinking) {
setAutoCollapsed(false);
setManualOpen(false);
}
if (!isActivelyThinking && wasActivelyThinking) {
setAutoCollapsed(false);
setManualOpen(false);
}
wasActivelyThinkingRef.current = isActivelyThinking;
}, [isActivelyThinking]);
const isOpen = isActivelyThinking ? !autoCollapsed : manualOpen;
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (isActivelyThinking) {
setAutoCollapsed(!nextOpen);
return;
}
setManualOpen(nextOpen);
},
[isActivelyThinking]
);
return (
<Reasoning
autoLifecycle={false}
isStreaming={isActivelyThinking}
onOpenChange={handleOpenChange}
open={isOpen}
>
<ReasoningTrigger />
<ReasoningContent>{content}</ReasoningContent>
</Reasoning>
);
}
function StreamingCursor() { function StreamingCursor() {
return ( return (
<span <span

View File

@ -7,6 +7,7 @@ import { issueSummary } from "@/client/api";
import { SettingsModalContext } from "@/components/settings/SettingsModalContext"; import { SettingsModalContext } from "@/components/settings/SettingsModalContext";
import { SettingsModal } from "@/components/settings/SettingsModal"; import { SettingsModal } from "@/components/settings/SettingsModal";
import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache"; import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache";
import { GlobalSearchPalette } from "@/components/search/GlobalSearchPalette";
import { initWsClient, getWsClient } from "@/ws/hooks"; import { initWsClient, getWsClient } from "@/ws/hooks";
import type { WsClient } from "@/ws/client"; import type { WsClient } from "@/ws/client";
@ -94,6 +95,7 @@ export function RootLayout() {
<SettingsDataCacheProvider> <SettingsDataCacheProvider>
<SettingsModalContext.Provider value={modalCtx}> <SettingsModalContext.Provider value={modalCtx}>
<Outlet /> <Outlet />
<GlobalSearchPalette />
{showSettingsModal && <SettingsModal />} {showSettingsModal && <SettingsModal />}
</SettingsModalContext.Provider> </SettingsModalContext.Provider>
</SettingsDataCacheProvider> </SettingsDataCacheProvider>

View File

@ -4,6 +4,8 @@ import { useCreateRepoMutation } from "@/hooks/useReposQuery";
import { useCreateRoomMutation } from "@/hooks/useRoomsQuery"; import { useCreateRoomMutation } from "@/hooks/useRoomsQuery";
import { useBoardOperations } from "@/hooks/useBoardOperations"; import { useBoardOperations } from "@/hooks/useBoardOperations";
import { useCreateSkillMutation } from "@/hooks/useSkillsQuery"; import { useCreateSkillMutation } from "@/hooks/useSkillsQuery";
import { projectInviteUser } from "@/client/api";
import type { MemberRole } from "@/client/model";
import { import {
X, X,
Hash, Hash,
@ -15,23 +17,33 @@ import {
FolderPlus, FolderPlus,
MessageSquarePlus, MessageSquarePlus,
Kanban, Kanban,
Zap Zap,
Mail,
UserPlus,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ProjectCreateMenuModalProps { interface ProjectCreateMenuModalProps {
onClose: () => void; onClose: () => void;
initialTab?: "repo" | "channel" | "board" | "skill"; initialTab?: "repo" | "channel" | "board" | "skill" | "invite";
} }
export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: ProjectCreateMenuModalProps) { export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: ProjectCreateMenuModalProps) {
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<"repo" | "channel" | "board" | "skill">(initialTab); const [activeTab, setActiveTab] = useState<"repo" | "channel" | "board" | "skill" | "invite">(initialTab);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -64,6 +76,12 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
}); });
const createSkill = useCreateSkillMutation(projectName); const createSkill = useCreateSkillMutation(projectName);
// --- Invite Form State ---
const [inviteForm, setInviteForm] = useState({
email: "",
scope: "Member" as MemberRole,
});
const handleCreateRepo = async (e: React.FormEvent) => { const handleCreateRepo = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!repoForm.repo_name.trim()) return; if (!repoForm.repo_name.trim()) return;
@ -149,6 +167,25 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
} }
}; };
const handleInviteUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!projectName || !inviteForm.email.trim()) return;
try {
setLoading(true);
setError(null);
await projectInviteUser(projectName, {
email: inviteForm.email.trim(),
scope: inviteForm.scope,
});
setInviteForm({ email: "", scope: "Member" });
onClose();
} catch (err: unknown) {
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to send invitation.");
} finally {
setLoading(false);
}
};
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div <div
@ -160,7 +197,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
<div className="px-1 pt-1 border-b" style={{ backgroundColor: "var(--surface-rail)", borderColor: "var(--border-default)" }}> <div className="px-1 pt-1 border-b" style={{ backgroundColor: "var(--surface-rail)", borderColor: "var(--border-default)" }}>
<div className="flex items-center justify-between px-4 py-2"> <div className="flex items-center justify-between px-4 py-2">
<span className="text-[14px] font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}> <span className="text-[14px] font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<PlusCircle className="w-4 h-4" style={{ color: "var(--accent)" }} /> Quick Create <PlusCircle className="w-4 h-4" style={{ color: "var(--accent)" }} /> Quick Start
</span> </span>
<button <button
onClick={onClose} onClick={onClose}
@ -176,6 +213,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
{ id: "channel", label: "Channel", icon: MessageSquarePlus }, { id: "channel", label: "Channel", icon: MessageSquarePlus },
{ id: "board", label: "Board", icon: Kanban }, { id: "board", label: "Board", icon: Kanban },
{ id: "skill", label: "Skill", icon: Zap }, { id: "skill", label: "Skill", icon: Zap },
{ id: "invite", label: "Invite", icon: UserPlus },
].map(tab => ( ].map(tab => (
<button <button
key={tab.id} key={tab.id}
@ -381,6 +419,56 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
</div> </div>
</form> </form>
)} )}
{activeTab === "invite" && (
<form onSubmit={handleInviteUser} className="space-y-5">
<div className="space-y-1.5">
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>Invite by Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: "var(--text-muted)" }} />
<Input
autoFocus
placeholder="user@example.com"
value={inviteForm.email}
onChange={e => setInviteForm({...inviteForm, email: e.target.value})}
className="border-none h-10 pl-9 text-[14px]"
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
type="email"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>Role</Label>
<Select
value={inviteForm.scope}
onValueChange={(value) => setInviteForm({...inviteForm, scope: value as MemberRole})}
>
<SelectTrigger className="h-10 border-none" style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="Member">Member</SelectItem>
<SelectItem value="Admin">Admin</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="pt-2 flex flex-col gap-3">
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
<Button
type="submit"
disabled={!inviteForm.email.trim() || loading}
className="w-full font-bold h-10"
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <UserPlus className="w-4 h-4 mr-2" />}
Send Invitation
</Button>
</div>
</form>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,6 +7,7 @@ import { useProjectInfo } from "@/hooks/useProjectInfo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { isProjectAdminRole, isProjectOwnerRole } from "@/lib/project-permissions";
const NEXT_ROLE: Record<string, string> = { admin: "member", member: "admin" }; const NEXT_ROLE: Record<string, string> = { admin: "member", member: "admin" };
@ -14,8 +15,8 @@ export function MembersSettings() {
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>();
const { data: projectInfo } = useProjectInfo(projectName); const { data: projectInfo } = useProjectInfo(projectName);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isCurrentAdmin = projectInfo?.role === "Owner" || projectInfo?.role === "Admin"; const isCurrentAdmin = isProjectAdminRole(projectInfo?.role);
const isCurrentOwner = projectInfo?.role === "Owner"; const isCurrentOwner = isProjectOwnerRole(projectInfo?.role);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["project-members-grouped", projectName], queryKey: ["project-members-grouped", projectName],
@ -64,7 +65,7 @@ export function MembersSettings() {
<div className="space-y-1 rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-default)" }}> <div className="space-y-1 rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-default)" }}>
{group.members.map(member => { {group.members.map(member => {
const isSelf = member.user_id === projectInfo?.created_by; const isSelf = member.user_id === projectInfo?.created_by;
const canManage = isCurrentAdmin && member.scope !== "Owner" && !isSelf; const canManage = isCurrentAdmin && !isProjectOwnerRole(member.scope) && !isSelf;
return ( return (
<div key={member.user_id} className="flex items-center gap-3 px-4 py-3" style={{ backgroundColor: "var(--surface-elevated)", borderBottom: "1px solid var(--border-subtle)" }}> <div key={member.user_id} className="flex items-center gap-3 px-4 py-3" style={{ backgroundColor: "var(--surface-elevated)", borderBottom: "1px solid var(--border-subtle)" }}>

View File

@ -1,12 +1,13 @@
import { Outlet, useParams, NavLink } from "react-router-dom"; import { Outlet, useParams, NavLink } from "react-router-dom";
import { useProjectInfo } from "@/hooks/useProjectInfo"; import { useProjectInfo } from "@/hooks/useProjectInfo";
import { Loader2, Lock, Settings, Users, Key, Tag, CreditCard } from "lucide-react"; import { Loader2, Lock, Settings, Users, Key, Tag, CreditCard } from "lucide-react";
import { isProjectAdminRole } from "@/lib/project-permissions";
export function ProjectSettingsLayout() { export function ProjectSettingsLayout() {
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>();
const { data: info, isLoading, error } = useProjectInfo(projectName); const { data: info, isLoading, error } = useProjectInfo(projectName);
const isAdmin = info?.role === "Owner" || info?.role === "Admin"; const isAdmin = isProjectAdminRole(info?.role);
const tabs = [ const tabs = [
{ to: "", end: true, icon: Settings, label: "General" }, { to: "", end: true, icon: Settings, label: "General" },

View File

@ -51,6 +51,7 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
defaultOpen?: boolean; defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
duration?: number; duration?: number;
autoLifecycle?: boolean;
}; };
const AUTO_CLOSE_DELAY = 1000; const AUTO_CLOSE_DELAY = 1000;
@ -64,6 +65,7 @@ export const Reasoning = memo(
defaultOpen, defaultOpen,
onOpenChange, onOpenChange,
duration: durationProp, duration: durationProp,
autoLifecycle = true,
children, children,
...props ...props
}: ReasoningProps) => { }: ReasoningProps) => {
@ -100,15 +102,16 @@ export const Reasoning = memo(
// Auto-open when streaming starts (unless explicitly closed) // Auto-open when streaming starts (unless explicitly closed)
useEffect(() => { useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) { if (autoLifecycle && isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true); setIsOpen(true);
} }
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]); }, [autoLifecycle, isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed) // Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => { useEffect(() => {
if ( if (
hasEverStreamedRef.current && hasEverStreamedRef.current &&
autoLifecycle &&
!isStreaming && !isStreaming &&
isOpen && isOpen &&
!hasAutoClosed !hasAutoClosed
@ -120,7 +123,7 @@ export const Reasoning = memo(
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]); }, [autoLifecycle, isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(newOpen: boolean) => { (newOpen: boolean) => {

View File

@ -5,6 +5,8 @@ import {useProjectInfo} from "@/hooks/useProjectInfo";
import {Hash, PanelLeftClose, Plus, Search, Settings} from "lucide-react"; import {Hash, PanelLeftClose, Plus, Search, Settings} from "lucide-react";
import {CHANNEL_SIDEBAR} from "@/css/layout/styles"; import {CHANNEL_SIDEBAR} from "@/css/layout/styles";
import {ProjectCreateMenuModal} from "@/app/project"; import {ProjectCreateMenuModal} from "@/app/project";
import { isProjectAdminRole } from "@/lib/project-permissions";
import { openGlobalSearch } from "@/components/search/global-search-events";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ {
@ -60,7 +62,7 @@ export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: Channel
const isSettingsActive = isActive("settings"); const isSettingsActive = isActive("settings");
const showSettings = projectInfo?.role === "Owner" || projectInfo?.role === "Admin"; const showSettings = isProjectAdminRole(projectInfo?.role);
const uncategorizedRooms = useMemo( const uncategorizedRooms = useMemo(
() => rooms.filter((r) => !r.isMuted && !r.category), () => rooms.filter((r) => !r.isMuted && !r.category),
@ -98,9 +100,10 @@ export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: Channel
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={openGlobalSearch}
className={CHANNEL_SIDEBAR.iconButton} className={CHANNEL_SIDEBAR.iconButton}
style={{color: "var(--text-secondary)"}} style={{color: "var(--text-secondary)"}}
title="Search" title="Search (Ctrl+Alt+F)"
> >
<Search className="w-[14px] h-[14px]"/> <Search className="w-[14px] h-[14px]"/>
</button> </button>

View File

@ -5,6 +5,8 @@ import { useProjectLayout } from "@/app/project/layout";
import { useProjectsQuery } from "@/hooks/useProjectsQuery"; import { useProjectsQuery } from "@/hooks/useProjectsQuery";
import { useOptionalRoom } from "@/contexts/room"; import { useOptionalRoom } from "@/contexts/room";
import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal"; import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal";
import { isProjectAdminRole } from "@/lib/project-permissions";
import { openGlobalSearch } from "@/components/search/global-search-events";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -42,15 +44,18 @@ const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [
{ label: "Invitations", path: "/me/invitations" }, { label: "Invitations", path: "/me/invitations" },
]; ];
function getProjectNavSiblings(projectName: string): BreadcrumbSibling[] { function getProjectNavSiblings(projectName: string, showSettings: boolean): BreadcrumbSibling[] {
return [ const items = [
{ label: "Repository", path: `/${projectName}/repos` }, { label: "Repository", path: `/${projectName}/repos` },
{ label: "Issues", path: `/${projectName}/issues` }, { label: "Issues", path: `/${projectName}/issues` },
{ label: "Skills", path: `/${projectName}/skills` }, { label: "Skills", path: `/${projectName}/skills` },
{ label: "Board", path: `/${projectName}/board` }, { label: "Board", path: `/${projectName}/board` },
{ label: "Chat", path: `/${projectName}/chat` }, { label: "Chat", path: `/${projectName}/chat` },
{ label: "Settings", path: `/${projectName}/settings` },
]; ];
if (showSettings) {
items.push({ label: "Settings", path: `/${projectName}/settings` });
}
return items;
} }
function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] { function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] {
@ -67,6 +72,7 @@ function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] {
function getSegmentSiblings( function getSegmentSiblings(
segment: BreadcrumbSegment, segment: BreadcrumbSegment,
projects: Array<{ name: string; display_name: string }>, projects: Array<{ name: string; display_name: string }>,
canManageProject = false,
): BreadcrumbSibling[] | null { ): BreadcrumbSibling[] | null {
if (segment.isLast) return null; if (segment.isLast) return null;
@ -86,7 +92,7 @@ function getSegmentSiblings(
if (depth === 2) { if (depth === 2) {
if (parts[0] === "me") return ME_NAV_SIBLINGS; if (parts[0] === "me") return ME_NAV_SIBLINGS;
return getProjectNavSiblings(parts[0]); return getProjectNavSiblings(parts[0], canManageProject);
} }
if (depth >= 3 && parts[1] === "repo") { if (depth >= 3 && parts[1] === "repo") {
@ -168,9 +174,10 @@ const TOOLBAR_ICONS = [
export const Header = memo(function Header() { export const Header = memo(function Header() {
const location = useLocation(); const location = useLocation();
const { segments, projects } = useBreadcrumbs(); const { segments, projects } = useBreadcrumbs();
const { isProjectMember, showMembers, setShowMembers } = useProjectLayout(); const { isProjectMember, projectInfo, showMembers, setShowMembers } = useProjectLayout();
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const roomContext = useOptionalRoom(); const roomContext = useOptionalRoom();
const canManageProject = isProjectAdminRole(projectInfo?.role);
const handleCopy = useCallback((e: React.MouseEvent, text: string) => { const handleCopy = useCallback((e: React.MouseEvent, text: string) => {
e.preventDefault(); e.preventDefault();
@ -195,7 +202,7 @@ export const Header = memo(function Header() {
</Link> </Link>
{segments.map((segment, idx) => { {segments.map((segment, idx) => {
const siblings = getSegmentSiblings(segment, projects); const siblings = getSegmentSiblings(segment, projects, canManageProject);
return ( return (
<span key={segment.path + idx} className="flex items-center gap-1 min-w-0"> <span key={segment.path + idx} className="flex items-center gap-1 min-w-0">
@ -297,9 +304,10 @@ export const Header = memo(function Header() {
{TOOLBAR_ICONS.map((icon, i) => ( {TOOLBAR_ICONS.map((icon, i) => (
<button <button
key={i} key={i}
onClick={icon.label === "Search" ? openGlobalSearch : undefined}
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors" className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors"
style={{ color: "var(--text-secondary)" }} style={{ color: "var(--text-secondary)" }}
title={icon.label} title={icon.label === "Search" ? "Search (Ctrl+Alt+F)" : icon.label}
> >
<svg <svg
className="w-[18px] h-[18px]" className="w-[18px] h-[18px]"

View File

@ -0,0 +1,248 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Boxes,
FolderGit2,
Hash,
Kanban,
LayoutDashboard,
MessageSquare,
Search,
Settings,
Sparkles,
Ticket,
} from "lucide-react";
import { search as globalSearch } from "@/client/api";
import { useBoardsQuery } from "@/hooks/useBoardsQuery";
import { useProjectInfo } from "@/hooks/useProjectInfo";
import { useProjectsQuery } from "@/hooks/useProjectsQuery";
import { useProjectReposQuery } from "@/hooks/useReposQuery";
import { useRoomsQuery } from "@/hooks/useRoomsQuery";
import { isProjectAdminRole } from "@/lib/project-permissions";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import { OPEN_GLOBAL_SEARCH_EVENT } from "@/components/search/global-search-events";
function useDebouncedValue(value: string, delay = 180) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(timer);
}, [delay, value]);
return debounced;
}
function getProjectNameFromPath(pathname: string) {
const first = pathname.split("/").filter(Boolean)[0];
if (!first || first === "me" || first === "auth" || first === "explore") return undefined;
return first;
}
export function GlobalSearchPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query.trim());
const location = useLocation();
const navigate = useNavigate();
const activeProjectName = getProjectNameFromPath(location.pathname);
const { data: projects = [] } = useProjectsQuery();
const { data: projectInfo } = useProjectInfo(activeProjectName);
const isProjectMember = !!projectInfo?.role;
const canManageProject = isProjectAdminRole(projectInfo?.role);
const { data: repos = [] } = useProjectReposQuery(isProjectMember ? activeProjectName : undefined);
const { data: roomsData } = useRoomsQuery(isProjectMember ? activeProjectName : undefined);
const { data: boards = [] } = useBoardsQuery(isProjectMember ? activeProjectName : undefined);
const remoteResults = useQuery({
queryKey: ["global-search-palette", debouncedQuery],
queryFn: async () => {
const res = await globalSearch({ q: debouncedQuery, type: "projects,repos,issues", page: 1, per_page: 8 });
return res.data?.data ?? null;
},
enabled: open && debouncedQuery.length >= 2,
staleTime: 30_000,
});
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.altKey && event.key.toLowerCase() === "f") {
event.preventDefault();
setOpen((current) => !current);
}
};
const onOpen = () => setOpen(true);
window.addEventListener("keydown", onKeyDown);
window.addEventListener(OPEN_GLOBAL_SEARCH_EVENT, onOpen);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener(OPEN_GLOBAL_SEARCH_EVENT, onOpen);
};
}, []);
const rooms = roomsData?.rooms ?? [];
const quickActions = useMemo(() => {
if (!activeProjectName) {
return [
{ label: "My Home", description: "Open personal overview", path: "/me", icon: LayoutDashboard },
{ label: "Explore Projects", description: "Find public projects", path: "/explore", icon: Search },
{ label: "Chat", description: "Open personal chat", path: "/me/chat", icon: MessageSquare },
];
}
return [
{ label: "Project Overview", description: activeProjectName, path: `/${activeProjectName}`, icon: LayoutDashboard },
{ label: "Repositories", description: activeProjectName, path: `/${activeProjectName}/repos`, icon: FolderGit2 },
{ label: "Issues", description: activeProjectName, path: `/${activeProjectName}/issues`, icon: Ticket },
{ label: "Boards", description: activeProjectName, path: `/${activeProjectName}/board`, icon: Kanban },
{ label: "Chat", description: activeProjectName, path: `/${activeProjectName}/chat`, icon: MessageSquare },
...(canManageProject
? [{ label: "Project Settings", description: activeProjectName, path: `/${activeProjectName}/settings`, icon: Settings }]
: []),
];
}, [activeProjectName, canManageProject]);
const go = (path: string) => {
setOpen(false);
setQuery("");
navigate(path);
};
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
};
return (
<CommandDialog
open={open}
onOpenChange={handleOpenChange}
title="Search"
description="Jump to projects, rooms, repositories, boards, and issues."
className="max-w-2xl"
>
<CommandInput
autoFocus
placeholder="Search projects, rooms, repos, boards, issues..."
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-[min(70vh,520px)]">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Quick Switch">
{quickActions.map((item) => (
<CommandItem key={item.path} value={`${item.label} ${item.description}`} onSelect={() => go(item.path)}>
<item.icon />
<span className="truncate">{item.label}</span>
<CommandShortcut>{item.description}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Projects">
{projects.map((project) => (
<CommandItem
key={project.uid}
value={`${project.display_name} ${project.name} project`}
onSelect={() => go(`/${project.name}`)}
>
<Boxes />
<span className="truncate">{project.display_name || project.name}</span>
<CommandShortcut>{project.name}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
{activeProjectName && (
<>
<CommandSeparator />
<CommandGroup heading={`Rooms in ${activeProjectName}`}>
{rooms.map((room) => (
<CommandItem
key={room.id}
value={`${room.room_name} room channel ${activeProjectName}`}
onSelect={() => go(`/${activeProjectName}/channel/${room.id}`)}
>
<Hash />
<span className="truncate">{room.room_name}</span>
<CommandShortcut>room</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Repositories">
{repos.map((repo) => (
<CommandItem
key={repo.uid}
value={`${repo.repo_name} ${repo.description ?? ""} repo ${activeProjectName}`}
onSelect={() => go(`/${activeProjectName}/repo/${repo.repo_name}`)}
>
<FolderGit2 />
<span className="truncate">{repo.repo_name}</span>
<CommandShortcut>repo</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Boards">
{boards.map((board) => (
<CommandItem
key={board.id}
value={`${board.name} ${board.description ?? ""} board ${activeProjectName}`}
onSelect={() => go(`/${activeProjectName}/board/${board.id}`)}
>
<Kanban />
<span className="truncate">{board.name}</span>
<CommandShortcut>board</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
</>
)}
{remoteResults.data && (
<>
<CommandSeparator />
<CommandGroup heading="Search Results">
{(remoteResults.data.projects?.items ?? []).map((project) => (
<CommandItem key={`remote-project-${project.uid}`} value={`${project.display_name} ${project.name}`} onSelect={() => go(`/${project.name}`)}>
<Boxes />
<span className="truncate">{project.display_name || project.name}</span>
<CommandShortcut>project</CommandShortcut>
</CommandItem>
))}
{(remoteResults.data.repos?.items ?? []).map((repo) => (
<CommandItem key={`remote-repo-${repo.uid}`} value={`${repo.name} ${repo.project_name}`} onSelect={() => go(`/${repo.project_name}/repo/${repo.name}`)}>
<FolderGit2 />
<span className="truncate">{repo.name}</span>
<CommandShortcut>{repo.project_name}</CommandShortcut>
</CommandItem>
))}
{(remoteResults.data.issues?.items ?? []).map((issue) => (
<CommandItem key={`remote-issue-${issue.uid}`} value={`${issue.title} ${issue.project_name} #${issue.number}`} onSelect={() => go(`/${issue.project_name}/issues/${issue.number}`)}>
<Sparkles />
<span className="truncate">#{issue.number} {issue.title}</span>
<CommandShortcut>issue</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
);
}

View File

@ -0,0 +1,5 @@
export const OPEN_GLOBAL_SEARCH_EVENT = "app:open-global-search";
export function openGlobalSearch() {
window.dispatchEvent(new Event(OPEN_GLOBAL_SEARCH_EVENT));
}

View File

@ -4,6 +4,49 @@ import { getAllPresets } from "@/config/theme-presets";
const STORAGE_KEY = "app-theme-preset"; const STORAGE_KEY = "app-theme-preset";
const DEFAULT_PRESET = "soft-mono"; const DEFAULT_PRESET = "soft-mono";
const THEME_COLOR_ALIASES: Record<string, string> = {
"color-background": "background",
"color-foreground": "foreground",
"color-card": "card",
"color-card-foreground": "card-foreground",
"color-popover": "popover",
"color-popover-foreground": "popover-foreground",
"color-primary": "primary",
"color-primary-foreground": "primary-foreground",
"color-secondary": "secondary",
"color-secondary-foreground": "secondary-foreground",
"color-muted": "muted",
"color-muted-foreground": "muted-foreground",
"color-accent": "accent",
"color-accent-foreground": "accent-foreground",
"color-destructive": "destructive",
"color-border": "border",
"color-input": "input",
"color-ring": "ring",
"color-sidebar": "sidebar",
"color-sidebar-foreground": "sidebar-foreground",
"color-sidebar-primary": "sidebar-primary",
"color-sidebar-primary-foreground": "sidebar-primary-foreground",
"color-sidebar-accent": "sidebar-accent",
"color-sidebar-accent-foreground": "sidebar-accent-foreground",
"color-sidebar-border": "sidebar-border",
"color-sidebar-ring": "sidebar-ring",
};
const DERIVED_THEME_TOKENS: Record<string, string> = {
"accent-bg": "color-mix(in oklch, var(--accent) 8%, transparent)",
"success-alpha10": "color-mix(in oklch, var(--success) 10%, transparent)",
"warning-alpha10": "color-mix(in oklch, var(--warning) 10%, transparent)",
"destructive-alpha10": "color-mix(in oklch, var(--destructive) 10%, transparent)",
"status-offline": "var(--text-muted)",
"text-tertiary": "color-mix(in oklch, var(--text-muted) 70%, var(--surface-ground))",
"heatmap-0": "color-mix(in oklch, var(--surface-ground) 82%, var(--border-default))",
"heatmap-1": "color-mix(in oklch, var(--success) 18%, var(--surface-ground))",
"heatmap-2": "color-mix(in oklch, var(--success) 36%, var(--surface-ground))",
"heatmap-3": "color-mix(in oklch, var(--success) 58%, var(--surface-ground))",
"heatmap-4": "color-mix(in oklch, var(--success) 78%, var(--surface-ground))",
};
export function useThemePreset() { export function useThemePreset() {
const [presetId, setPresetIdState] = useState<string>(() => { const [presetId, setPresetIdState] = useState<string>(() => {
return localStorage.getItem(STORAGE_KEY) || DEFAULT_PRESET; return localStorage.getItem(STORAGE_KEY) || DEFAULT_PRESET;
@ -33,6 +76,14 @@ export function applyThemePreset(presetId: string) {
Object.entries(vars).forEach(([key, value]) => { Object.entries(vars).forEach(([key, value]) => {
if (value) root.style.setProperty(`--${key}`, value); if (value) root.style.setProperty(`--${key}`, value);
}); });
Object.entries(THEME_COLOR_ALIASES).forEach(([alias, source]) => {
root.style.setProperty(`--${alias}`, `var(--${source})`);
});
Object.entries(DERIVED_THEME_TOKENS).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value);
});
} }
export function ThemePresetSelector() { export function ThemePresetSelector() {

View File

@ -0,0 +1,10 @@
import type { MemberRole } from "@/client/model";
export function isProjectAdminRole(role?: MemberRole | string | null) {
const normalized = String(role ?? "").toLowerCase();
return normalized === "owner" || normalized === "admin";
}
export function isProjectOwnerRole(role?: MemberRole | string | null) {
return String(role ?? "").toLowerCase() === "owner";
}

View File

@ -26,7 +26,7 @@ const numberFromEnv = (value: string | undefined, fallback: number) => {
}; };
const options: OpenObserveOptions = { const options: OpenObserveOptions = {
clientToken: import.meta.env.VITE_OPENOBSERVE_CLIENT_TOKEN ?? 'rum88J1PMrMSTcFoP0g', clientToken: import.meta.env.VITE_OPENOBSERVE_CLIENT_TOKEN ?? 'rumZUDgk3BCRAAOKcts',
applicationId: import.meta.env.VITE_OPENOBSERVE_APPLICATION_ID ?? 'GitDataAI', applicationId: import.meta.env.VITE_OPENOBSERVE_APPLICATION_ID ?? 'GitDataAI',
site: import.meta.env.VITE_OPENOBSERVE_SITE ?? 'ob.gitdata.me', site: import.meta.env.VITE_OPENOBSERVE_SITE ?? 'ob.gitdata.me',
service: import.meta.env.VITE_OPENOBSERVE_SERVICE ?? 'GitDataAIWeb', service: import.meta.env.VITE_OPENOBSERVE_SERVICE ?? 'GitDataAIWeb',