mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
50
web/components/collaboration/AnnotationBadge.tsx
Normal file
50
web/components/collaboration/AnnotationBadge.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnnotationThread } from "./AnnotationThread";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
|
||||
interface AnnotationBadgeProps {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export function AnnotationBadge({ messageId }: AnnotationBadgeProps) {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
const annotations = ctx.annotations[messageId] ?? [];
|
||||
const unresolved = annotations.filter((a) => !a.resolved);
|
||||
if (annotations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium",
|
||||
"transition-colors border",
|
||||
unresolved.length > 0
|
||||
? "bg-amber-900/30 border-amber-700/50 text-amber-300 hover:bg-amber-900/50"
|
||||
: "bg-surface-800 border-surface-700 text-surface-400 hover:bg-surface-700"
|
||||
)}
|
||||
title={`${annotations.length} comment${annotations.length !== 1 ? "s" : ""}`}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{unresolved.length > 0 ? unresolved.length : annotations.length}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-40 w-80"
|
||||
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
|
||||
>
|
||||
<AnnotationThread messageId={messageId} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
web/components/collaboration/CollaborationProvider.tsx
Normal file
139
web/components/collaboration/CollaborationProvider.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useRef, useMemo } from "react";
|
||||
import { useCollaboration } from "@/hooks/useCollaboration";
|
||||
import { usePresence } from "@/hooks/usePresence";
|
||||
import { CollabSocket } from "@/lib/collaboration/socket";
|
||||
import type { CollabUser, CollabRole } from "@/lib/collaboration/socket";
|
||||
import type { CollabAnnotation, PendingToolUse } from "@/lib/collaboration/types";
|
||||
import type { PresenceState } from "@/lib/collaboration/presence";
|
||||
import type { LinkExpiry, ShareLink } from "@/lib/collaboration/types";
|
||||
import { createShareLink } from "@/lib/collaboration/permissions";
|
||||
|
||||
// ─── Context Shape ────────────────────────────────────────────────────────────
|
||||
|
||||
interface CollaborationContextValue {
|
||||
// Connection
|
||||
isConnected: boolean;
|
||||
sessionId: string;
|
||||
currentUser: CollabUser;
|
||||
|
||||
// Roles & policy
|
||||
myRole: CollabRole | null;
|
||||
toolApprovalPolicy: "owner-only" | "any-collaborator";
|
||||
|
||||
// Presence
|
||||
presence: PresenceState;
|
||||
otherUsers: CollabUser[];
|
||||
typingUsers: CollabUser[];
|
||||
|
||||
// Tool approvals
|
||||
pendingToolUses: PendingToolUse[];
|
||||
approveTool: (toolUseId: string) => void;
|
||||
denyTool: (toolUseId: string) => void;
|
||||
|
||||
// Annotations
|
||||
annotations: Record<string, CollabAnnotation[]>;
|
||||
addAnnotation: (messageId: string, text: string) => void;
|
||||
resolveAnnotation: (annotationId: string, resolved: boolean) => void;
|
||||
replyAnnotation: (annotationId: string, text: string) => void;
|
||||
|
||||
// Presence actions
|
||||
sendCursorUpdate: (pos: number, start?: number, end?: number) => void;
|
||||
notifyTyping: () => void;
|
||||
stopTyping: () => void;
|
||||
|
||||
// Session management
|
||||
generateShareLink: (role: CollabRole, expiry: LinkExpiry) => ShareLink;
|
||||
revokeAccess: (userId: string) => void;
|
||||
changeRole: (userId: string, role: CollabRole) => void;
|
||||
transferOwnership: (userId: string) => void;
|
||||
}
|
||||
|
||||
const CollaborationContext = createContext<CollaborationContextValue | null>(null);
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CollaborationProviderProps {
|
||||
sessionId: string;
|
||||
currentUser: CollabUser;
|
||||
wsUrl?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CollaborationProvider({
|
||||
sessionId,
|
||||
currentUser,
|
||||
wsUrl,
|
||||
children,
|
||||
}: CollaborationProviderProps) {
|
||||
const socketRef = useRef<CollabSocket | null>(null);
|
||||
|
||||
const collab = useCollaboration({ sessionId, currentUser, wsUrl });
|
||||
|
||||
// Access the socket from the ref (set by the hook internally)
|
||||
// Since useCollaboration creates the socket internally, we expose a proxy
|
||||
// via the presence hook's socket param by reaching into the hook return
|
||||
const presence = usePresence({
|
||||
socket: socketRef.current,
|
||||
sessionId,
|
||||
currentUser,
|
||||
});
|
||||
|
||||
const generateShareLink = useMemo(
|
||||
() => (role: CollabRole, expiry: LinkExpiry) =>
|
||||
createShareLink(sessionId, role, expiry, currentUser.id),
|
||||
[sessionId, currentUser.id]
|
||||
);
|
||||
|
||||
const value: CollaborationContextValue = {
|
||||
isConnected: collab.isConnected,
|
||||
sessionId,
|
||||
currentUser,
|
||||
myRole: collab.myRole,
|
||||
toolApprovalPolicy: collab.toolApprovalPolicy,
|
||||
presence: presence.presence,
|
||||
otherUsers: presence.otherUsers,
|
||||
typingUsers: presence.typingUsers,
|
||||
pendingToolUses: collab.pendingToolUses,
|
||||
approveTool: collab.approveTool,
|
||||
denyTool: collab.denyTool,
|
||||
annotations: collab.annotations,
|
||||
addAnnotation: collab.addAnnotation,
|
||||
resolveAnnotation: collab.resolveAnnotation,
|
||||
replyAnnotation: collab.replyAnnotation,
|
||||
sendCursorUpdate: presence.sendCursorUpdate,
|
||||
notifyTyping: presence.notifyTyping,
|
||||
stopTyping: presence.stopTyping,
|
||||
generateShareLink,
|
||||
revokeAccess: collab.revokeAccess,
|
||||
changeRole: collab.changeRole,
|
||||
transferOwnership: collab.transferOwnership,
|
||||
};
|
||||
|
||||
return (
|
||||
<CollaborationContext.Provider value={value}>
|
||||
{children}
|
||||
</CollaborationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Consumer Hook ────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCollaborationContext(): CollaborationContextValue {
|
||||
const ctx = useContext(CollaborationContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useCollaborationContext must be used inside <CollaborationProvider>"
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null when there is no active collaboration session.
|
||||
* Use this in components that render outside a CollaborationProvider.
|
||||
*/
|
||||
export function useCollaborationContextOptional(): CollaborationContextValue | null {
|
||||
return useContext(CollaborationContext);
|
||||
}
|
||||
122
web/components/collaboration/CursorGhost.tsx
Normal file
122
web/components/collaboration/CursorGhost.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
import type { CursorState } from "@/lib/collaboration/presence";
|
||||
import type { CollabUser } from "@/lib/collaboration/socket";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CursorGhostProps {
|
||||
/** The textarea ref to measure cursor positions against */
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
interface RenderedCursor {
|
||||
user: CollabUser;
|
||||
cursor: CursorState;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Approximates pixel position of a text offset inside a textarea.
|
||||
* Uses a hidden mirror div that matches the textarea's styling.
|
||||
*/
|
||||
function measureCursorPosition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
offset: number
|
||||
): { top: number; left: number } {
|
||||
const mirror = document.createElement("div");
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
|
||||
mirror.style.position = "absolute";
|
||||
mirror.style.visibility = "hidden";
|
||||
mirror.style.whiteSpace = "pre-wrap";
|
||||
mirror.style.wordWrap = "break-word";
|
||||
mirror.style.width = computed.width;
|
||||
mirror.style.font = computed.font;
|
||||
mirror.style.lineHeight = computed.lineHeight;
|
||||
mirror.style.padding = computed.padding;
|
||||
mirror.style.border = computed.border;
|
||||
mirror.style.boxSizing = computed.boxSizing;
|
||||
|
||||
const text = textarea.value.slice(0, offset);
|
||||
mirror.textContent = text;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "\u200b"; // zero-width space
|
||||
mirror.appendChild(span);
|
||||
|
||||
document.body.appendChild(mirror);
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
document.body.removeChild(mirror);
|
||||
|
||||
return {
|
||||
top: spanRect.top - rect.top + textarea.scrollTop,
|
||||
left: spanRect.left - rect.left,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── CursorGhost ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function CursorGhost({ textareaRef }: CursorGhostProps) {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
const [rendered, setRendered] = useState<RenderedCursor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx || !textareaRef.current) return;
|
||||
const textarea = textareaRef.current;
|
||||
const { presence, otherUsers } = ctx;
|
||||
|
||||
const next: RenderedCursor[] = [];
|
||||
for (const user of otherUsers) {
|
||||
const cursor = presence.cursors.get(user.id);
|
||||
if (!cursor) continue;
|
||||
try {
|
||||
const pos = measureCursorPosition(textarea, cursor.position);
|
||||
next.push({ user, cursor, ...pos });
|
||||
} catch {
|
||||
// ignore measurement errors
|
||||
}
|
||||
}
|
||||
setRendered(next);
|
||||
});
|
||||
|
||||
if (!ctx || rendered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<AnimatePresence>
|
||||
{rendered.map(({ user, top, left }) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute flex flex-col items-start"
|
||||
style={{ top, left }}
|
||||
>
|
||||
{/* Cursor caret */}
|
||||
<div
|
||||
className="w-0.5 h-4"
|
||||
style={{ backgroundColor: user.color }}
|
||||
/>
|
||||
{/* Name tag */}
|
||||
<div
|
||||
className="px-1 py-0.5 rounded text-[9px] font-semibold text-white whitespace-nowrap"
|
||||
style={{ backgroundColor: user.color }}
|
||||
>
|
||||
{user.name}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
web/components/collaboration/PresenceAvatars.tsx
Normal file
136
web/components/collaboration/PresenceAvatars.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { getInitials } from "@/lib/collaboration/presence";
|
||||
import { labelForRole } from "@/lib/collaboration/permissions";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Single Avatar ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AvatarProps {
|
||||
name: string;
|
||||
color: string;
|
||||
avatar?: string;
|
||||
role: import("@/lib/collaboration/socket").CollabRole;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
function UserAvatar({ name, color, avatar, role, isActive = true }: AvatarProps) {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className="relative w-7 h-7 rounded-full flex-shrink-0 cursor-default select-none"
|
||||
style={{ boxShadow: `0 0 0 2px ${color}` }}
|
||||
>
|
||||
{avatar ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={avatar}
|
||||
alt={name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full rounded-full flex items-center justify-center text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Online indicator dot */}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 right-0 w-2 h-2 rounded-full bg-green-400 border border-surface-900" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"z-50 rounded-md px-2.5 py-1.5 text-xs shadow-md",
|
||||
"bg-surface-800 border border-surface-700 text-surface-100"
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{name}</p>
|
||||
<p className="text-surface-400">{labelForRole(role)}</p>
|
||||
<Tooltip.Arrow className="fill-surface-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PresenceAvatars ──────────────────────────────────────────────────────────
|
||||
|
||||
export function PresenceAvatars() {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
if (!ctx) return null;
|
||||
|
||||
const { isConnected, otherUsers, currentUser } = ctx;
|
||||
// Show at most 4 avatars + overflow badge
|
||||
const MAX_VISIBLE = 4;
|
||||
const allUsers = [currentUser, ...otherUsers];
|
||||
const visible = allUsers.slice(0, MAX_VISIBLE);
|
||||
const overflow = allUsers.length - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Connection indicator */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isConnected ? (
|
||||
<Wifi className="w-3.5 h-3.5 text-green-400" />
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5 text-surface-500 animate-pulse" />
|
||||
)}
|
||||
<span className="text-xs text-surface-500 hidden sm:inline">
|
||||
{isConnected
|
||||
? `${allUsers.length} online`
|
||||
: "Reconnecting…"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stacked avatars */}
|
||||
<div className="flex items-center">
|
||||
<AnimatePresence>
|
||||
{visible.map((user, i) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, scale: 0.5, x: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={{ duration: 0.2, delay: i * 0.04 }}
|
||||
style={{ zIndex: visible.length - i, marginLeft: i === 0 ? 0 : -8 }}
|
||||
>
|
||||
<UserAvatar
|
||||
name={user.id === currentUser.id ? `${user.name} (you)` : user.name}
|
||||
color={user.color}
|
||||
avatar={user.avatar}
|
||||
role={user.role}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{overflow > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-full -ml-2 z-0 flex items-center justify-center",
|
||||
"bg-surface-700 border-2 border-surface-900 text-[10px] font-medium text-surface-300"
|
||||
)}
|
||||
>
|
||||
+{overflow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
web/components/collaboration/TypingIndicator.tsx
Normal file
73
web/components/collaboration/TypingIndicator.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
|
||||
// ─── Animated dots ────────────────────────────────────────────────────────────
|
||||
|
||||
function Dots() {
|
||||
return (
|
||||
<span className="inline-flex items-end gap-0.5 h-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="w-1 h-1 rounded-full bg-surface-400 inline-block"
|
||||
animate={{ y: [0, -3, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.15,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TypingIndicator ──────────────────────────────────────────────────────────
|
||||
|
||||
export function TypingIndicator() {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
if (!ctx) return null;
|
||||
|
||||
const { typingUsers } = ctx;
|
||||
if (typingUsers.length === 0) return null;
|
||||
|
||||
let label: string;
|
||||
if (typingUsers.length === 1) {
|
||||
label = `${typingUsers[0].name} is typing`;
|
||||
} else if (typingUsers.length === 2) {
|
||||
label = `${typingUsers[0].name} and ${typingUsers[1].name} are typing`;
|
||||
} else {
|
||||
label = `${typingUsers.length} people are typing`;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="typing-indicator"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex items-center gap-1.5 px-4 pb-1 text-xs text-surface-400"
|
||||
>
|
||||
{/* Colored dots for each typing user */}
|
||||
<span className="flex -space-x-1">
|
||||
{typingUsers.slice(0, 3).map((u) => (
|
||||
<span
|
||||
key={u.id}
|
||||
className="w-4 h-4 rounded-full border border-surface-900 flex items-center justify-center text-[8px] font-bold text-white"
|
||||
style={{ backgroundColor: u.color }}
|
||||
>
|
||||
{u.name[0].toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
<Dots />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user