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,100 @@
import type { CollabRole } from "./socket";
import type { LinkExpiry, ShareLink } from "./types";
import { nanoid } from "nanoid";
// ─── Role Hierarchy ───────────────────────────────────────────────────────────
const ROLE_RANK: Record<CollabRole, number> = {
owner: 3,
collaborator: 2,
viewer: 1,
};
export function hasPermission(role: CollabRole, required: CollabRole): boolean {
return ROLE_RANK[role] >= ROLE_RANK[required];
}
export function canSendMessages(role: CollabRole): boolean {
return hasPermission(role, "collaborator");
}
export function canApproveTools(
role: CollabRole,
policy: "owner-only" | "any-collaborator",
isOwner: boolean
): boolean {
if (policy === "owner-only") return isOwner;
return hasPermission(role, "collaborator");
}
export function canManageAccess(role: CollabRole): boolean {
return role === "owner";
}
export function canTransferOwnership(role: CollabRole): boolean {
return role === "owner";
}
export function canAddAnnotations(role: CollabRole): boolean {
return hasPermission(role, "collaborator");
}
export function canChangeRole(
actorRole: CollabRole,
targetRole: CollabRole
): boolean {
// Owner can change any role; collaborators cannot manage access at all
return actorRole === "owner" && targetRole !== "owner";
}
// ─── Share Links ──────────────────────────────────────────────────────────────
const EXPIRY_MS: Record<LinkExpiry, number | null> = {
"1h": 60 * 60 * 1_000,
"24h": 24 * 60 * 60 * 1_000,
"7d": 7 * 24 * 60 * 60 * 1_000,
never: null,
};
export function createShareLink(
sessionId: string,
role: CollabRole,
expiry: LinkExpiry,
createdBy: string
): ShareLink {
const expiryMs = EXPIRY_MS[expiry];
return {
id: nanoid(12),
sessionId,
role,
createdAt: Date.now(),
expiresAt: expiryMs !== null ? Date.now() + expiryMs : null,
createdBy,
};
}
export function isLinkExpired(link: ShareLink): boolean {
return link.expiresAt !== null && Date.now() > link.expiresAt;
}
export function buildShareUrl(linkId: string, role: CollabRole): string {
const base =
typeof window !== "undefined"
? window.location.origin
: "http://localhost:3000";
return `${base}/join/${linkId}?role=${role}`;
}
export function labelForRole(role: CollabRole): string {
return { owner: "Owner", collaborator: "Collaborator", viewer: "Viewer" }[
role
];
}
export function descriptionForRole(role: CollabRole): string {
return {
owner: "Full control — can send messages, approve tools, and manage access",
collaborator: "Can send messages and approve tool use",
viewer: "Read-only — can watch the session in real-time",
}[role];
}

View File

@@ -0,0 +1,115 @@
import type { CollabUser } from "./socket";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CursorState {
userId: string;
position: number;
selectionStart?: number;
selectionEnd?: number;
updatedAt: number;
}
export interface PresenceState {
users: Map<string, CollabUser>;
cursors: Map<string, CursorState>;
typing: Set<string>; // user IDs currently typing
}
// ─── Factory ──────────────────────────────────────────────────────────────────
export function createPresenceState(): PresenceState {
return {
users: new Map(),
cursors: new Map(),
typing: new Set(),
};
}
// ─── Updaters (all return new state, immutable) ───────────────────────────────
export function presenceAddUser(
state: PresenceState,
user: CollabUser
): PresenceState {
return { ...state, users: new Map(state.users).set(user.id, user) };
}
export function presenceRemoveUser(
state: PresenceState,
userId: string
): PresenceState {
const users = new Map(state.users);
users.delete(userId);
const cursors = new Map(state.cursors);
cursors.delete(userId);
const typing = new Set(state.typing);
typing.delete(userId);
return { users, cursors, typing };
}
export function presenceSyncUsers(
state: PresenceState,
users: CollabUser[]
): PresenceState {
const map = new Map<string, CollabUser>();
users.forEach((u) => map.set(u.id, u));
return { ...state, users: map };
}
export function presenceUpdateCursor(
state: PresenceState,
userId: string,
position: number,
selectionStart?: number,
selectionEnd?: number
): PresenceState {
const cursor: CursorState = {
userId,
position,
selectionStart,
selectionEnd,
updatedAt: Date.now(),
};
return { ...state, cursors: new Map(state.cursors).set(userId, cursor) };
}
export function presenceSetTyping(
state: PresenceState,
userId: string,
isTyping: boolean
): PresenceState {
const typing = new Set(state.typing);
if (isTyping) {
typing.add(userId);
} else {
typing.delete(userId);
}
return { ...state, typing };
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const USER_COLORS = [
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#8b5cf6",
"#ec4899",
];
export function getUserColor(index: number): string {
return USER_COLORS[index % USER_COLORS.length];
}
export function getInitials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.slice(0, 2)
.join("")
.toUpperCase();
}

View File

@@ -0,0 +1,321 @@
"use client";
// ─── Role & User ────────────────────────────────────────────────────────────
export type CollabRole = "owner" | "collaborator" | "viewer";
export interface CollabUser {
id: string;
name: string;
email: string;
avatar?: string;
color: string; // hex color assigned per session
role: CollabRole;
}
// ─── Event Types ─────────────────────────────────────────────────────────────
export type CollabEventType =
| "message_added"
| "message_streaming"
| "tool_use_pending"
| "tool_use_approved"
| "tool_use_denied"
| "user_joined"
| "user_left"
| "cursor_update"
| "typing_start"
| "typing_stop"
| "presence_sync"
| "annotation_added"
| "annotation_resolved"
| "annotation_reply"
| "role_changed"
| "access_revoked"
| "ownership_transferred"
| "session_state"
| "error";
interface BaseCollabEvent {
type: CollabEventType;
sessionId: string;
userId: string;
timestamp: number;
}
export interface MessageAddedEvent extends BaseCollabEvent {
type: "message_added";
message: {
id: string;
role: string;
content: unknown;
status: string;
createdAt: number;
};
}
export interface MessageStreamingEvent extends BaseCollabEvent {
type: "message_streaming";
messageId: string;
delta: string;
done: boolean;
}
export interface ToolUsePendingEvent extends BaseCollabEvent {
type: "tool_use_pending";
toolUseId: string;
toolName: string;
toolInput: Record<string, unknown>;
messageId: string;
}
export interface ToolUseApprovedEvent extends BaseCollabEvent {
type: "tool_use_approved";
toolUseId: string;
approvedBy: CollabUser;
}
export interface ToolUseDeniedEvent extends BaseCollabEvent {
type: "tool_use_denied";
toolUseId: string;
deniedBy: CollabUser;
}
export interface UserJoinedEvent extends BaseCollabEvent {
type: "user_joined";
user: CollabUser;
}
export interface UserLeftEvent extends BaseCollabEvent {
type: "user_left";
user: CollabUser;
}
export interface CursorUpdateEvent extends BaseCollabEvent {
type: "cursor_update";
position: number;
selectionStart?: number;
selectionEnd?: number;
}
export interface TypingStartEvent extends BaseCollabEvent {
type: "typing_start";
user: CollabUser;
}
export interface TypingStopEvent extends BaseCollabEvent {
type: "typing_stop";
user: CollabUser;
}
export interface PresenceSyncEvent extends BaseCollabEvent {
type: "presence_sync";
users: CollabUser[];
}
export interface AnnotationAddedEvent extends BaseCollabEvent {
type: "annotation_added";
annotation: {
id: string;
messageId: string;
parentId?: string;
text: string;
author: CollabUser;
createdAt: number;
resolved: boolean;
replies: AnnotationReply[];
};
}
export interface AnnotationResolvedEvent extends BaseCollabEvent {
type: "annotation_resolved";
annotationId: string;
resolved: boolean;
resolvedBy: CollabUser;
}
export interface AnnotationReplyEvent extends BaseCollabEvent {
type: "annotation_reply";
annotationId: string;
reply: AnnotationReply;
}
export interface AnnotationReply {
id: string;
text: string;
author: CollabUser;
createdAt: number;
}
export interface RoleChangedEvent extends BaseCollabEvent {
type: "role_changed";
targetUserId: string;
newRole: CollabRole;
}
export interface AccessRevokedEvent extends BaseCollabEvent {
type: "access_revoked";
targetUserId: string;
}
export interface OwnershipTransferredEvent extends BaseCollabEvent {
type: "ownership_transferred";
newOwnerId: string;
previousOwnerId: string;
}
export interface SessionStateEvent extends BaseCollabEvent {
type: "session_state";
users: CollabUser[];
pendingToolUseIds: string[];
toolApprovalPolicy: "owner-only" | "any-collaborator";
}
export interface ErrorEvent extends BaseCollabEvent {
type: "error";
code: string;
message: string;
}
export type CollabEvent =
| MessageAddedEvent
| MessageStreamingEvent
| ToolUsePendingEvent
| ToolUseApprovedEvent
| ToolUseDeniedEvent
| UserJoinedEvent
| UserLeftEvent
| CursorUpdateEvent
| TypingStartEvent
| TypingStopEvent
| PresenceSyncEvent
| AnnotationAddedEvent
| AnnotationResolvedEvent
| AnnotationReplyEvent
| RoleChangedEvent
| AccessRevokedEvent
| OwnershipTransferredEvent
| SessionStateEvent
| ErrorEvent;
// Outgoing-only events (client → server) that don't need timestamp/sessionId
export type OutgoingEvent = Omit<CollabEvent, "timestamp">;
type EventHandler<T extends CollabEvent = CollabEvent> = (event: T) => void;
// ─── CollabSocket ─────────────────────────────────────────────────────────────
export class CollabSocket {
private ws: WebSocket | null = null;
private handlers = new Map<CollabEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly baseDelay = 1000;
private pingInterval: ReturnType<typeof setInterval> | null = null;
private wsUrl = "";
readonly sessionId: string;
readonly token: string;
isConnected = false;
onConnectionChange?: (connected: boolean) => void;
constructor(sessionId: string, token: string) {
this.sessionId = sessionId;
this.token = token;
}
connect(wsUrl: string): void {
this.wsUrl = wsUrl;
if (this.ws?.readyState === WebSocket.OPEN) return;
const url = new URL(wsUrl);
url.searchParams.set("sessionId", this.sessionId);
url.searchParams.set("token", this.token);
this.ws = new WebSocket(url.toString());
this.ws.onopen = () => {
this.isConnected = true;
this.reconnectAttempts = 0;
this.onConnectionChange?.(true);
this.startPing();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string) as CollabEvent;
this.dispatch(data);
} catch {
// ignore malformed frames
}
};
this.ws.onclose = () => {
this.isConnected = false;
this.onConnectionChange?.(false);
this.stopPing();
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
}
disconnect(): void {
// Set max attempts to prevent reconnect
this.reconnectAttempts = this.maxReconnectAttempts;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopPing();
this.ws?.close();
this.ws = null;
}
send(event: OutgoingEvent): void {
if (this.ws?.readyState !== WebSocket.OPEN) return;
this.ws.send(JSON.stringify({ ...event, timestamp: Date.now() }));
}
on<T extends CollabEvent>(
type: T["type"],
handler: EventHandler<T>
): () => void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set());
}
this.handlers.get(type)!.add(handler as EventHandler);
return () => this.off(type, handler);
}
off<T extends CollabEvent>(type: T["type"], handler: EventHandler<T>): void {
this.handlers.get(type)?.delete(handler as EventHandler);
}
private dispatch(event: CollabEvent): void {
this.handlers.get(event.type)?.forEach((h) => h(event));
}
private startPing(): void {
this.pingInterval = setInterval(() => {
this.ws?.send(JSON.stringify({ type: "ping" }));
}, 30_000);
}
private stopPing(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
const delay = this.baseDelay * Math.pow(2, this.reconnectAttempts);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => this.connect(this.wsUrl), delay);
}
}

View File

@@ -0,0 +1,37 @@
import type { CollabUser, AnnotationReply } from "./socket";
// ─── Annotation ───────────────────────────────────────────────────────────────
export interface CollabAnnotation {
id: string;
messageId: string;
parentId?: string; // for threaded top-level grouping (unused in v1)
text: string;
author: CollabUser;
createdAt: number;
resolved: boolean;
replies: AnnotationReply[];
}
// ─── Pending Tool Use ─────────────────────────────────────────────────────────
export interface PendingToolUse {
id: string; // toolUseId
name: string;
input: Record<string, unknown>;
messageId: string;
requestedAt: number;
}
// ─── Share Link ───────────────────────────────────────────────────────────────
export type LinkExpiry = "1h" | "24h" | "7d" | "never";
export interface ShareLink {
id: string;
sessionId: string;
role: import("./socket").CollabRole;
createdAt: number;
expiresAt: number | null; // null = never expires
createdBy: string;
}