claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

View 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>
);
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}