From 32a430adafb7f10aa1b9f67de1eabe7181e20abc Mon Sep 17 00:00:00 2001 From: amelitoalcuitas Date: Fri, 15 May 2026 09:49:55 +0800 Subject: [PATCH 1/6] feat: add teams chat list feature --- apps/api/src/routes/chats.ts | 428 ++++++++++++- apps/api/src/routes/public-chat-shares.ts | 7 +- apps/code/src/lib/api/v1.d.ts | 150 ++++- .../app/org/[orgId]/chat/[shareId]/page.tsx | 25 + apps/playground/src/app/org/[orgId]/page.tsx | 25 + apps/playground/src/app/page.tsx | 163 +---- apps/playground/src/app/playground-shell.tsx | 192 ++++++ .../src/components/playground/chat-header.tsx | 29 +- .../playground/chat-page-client.tsx | 71 ++- .../components/playground/chat-sidebar.tsx | 25 +- .../playground/fork-chat-button.tsx | 14 +- .../src/components/playground/org-header.tsx | 38 ++ .../components/playground/org-page-client.tsx | 240 ++++++++ .../playground/org-search-dialog.tsx | 322 ++++++++++ .../src/components/playground/org-sidebar.tsx | 579 ++++++++++++++++++ .../playground/organization-switcher.tsx | 58 +- .../playground/share-chat-dialog.tsx | 282 ++++++++- apps/playground/src/hooks/useChats.ts | 101 ++- apps/playground/src/lib/api/v1.d.ts | 150 ++++- apps/ui/src/lib/api/v1.d.ts | 150 ++++- ee/admin/src/lib/api/v1.d.ts | 150 ++++- packages/db/src/schema.ts | 15 +- 22 files changed, 2957 insertions(+), 257 deletions(-) create mode 100644 apps/playground/src/app/org/[orgId]/chat/[shareId]/page.tsx create mode 100644 apps/playground/src/app/org/[orgId]/page.tsx create mode 100644 apps/playground/src/app/playground-shell.tsx create mode 100644 apps/playground/src/components/playground/org-header.tsx create mode 100644 apps/playground/src/components/playground/org-page-client.tsx create mode 100644 apps/playground/src/components/playground/org-search-dialog.tsx create mode 100644 apps/playground/src/components/playground/org-sidebar.tsx diff --git a/apps/api/src/routes/chats.ts b/apps/api/src/routes/chats.ts index 3f16b39a2d..64febadd12 100644 --- a/apps/api/src/routes/chats.ts +++ b/apps/api/src/routes/chats.ts @@ -2,6 +2,7 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; import { hasActiveApiKey } from "@/lib/hasActiveApiKey.js"; +import { userHasOrganizationAccess } from "@/utils/authorization.js"; import { db, @@ -13,6 +14,7 @@ import { count, and, isNull, + isNotNull, sql, or, ilike, @@ -31,6 +33,8 @@ const chatSchema = z.object({ webSearch: z.boolean(), shareId: z.string().nullable(), sharedAt: z.string().datetime().nullable(), + orgShareId: z.string().nullable(), + orgShareOrganizationId: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), messageCount: z.number(), @@ -53,6 +57,40 @@ const shareSchema = z.object({ id: z.string(), url: z.string(), createdAt: z.string().datetime(), + organizationId: z.string().nullable().optional(), +}); + +const shareChatSchema = z.object({ + organizationId: z.string().optional(), +}); + +const orgShareListItemSchema = z.object({ + id: z.string(), + title: z.string(), + model: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +const orgShareSchema = z.object({ + id: z.string(), + title: z.string(), + model: z.string(), + createdAt: z.string().datetime(), + messages: z.array( + z.object({ + id: z.string(), + role: z.enum(["user", "assistant", "system"]), + content: z.string().nullable(), + images: z.string().nullable(), + audios: z.string().nullable().optional(), + reasoning: z.string().nullable(), + tools: z.string().nullable(), + metadata: z.record(z.unknown()).nullable().optional(), + sequence: z.number(), + createdAt: z.string().datetime(), + }), + ), }); const sharedMessageSnapshotSchema = z.array( @@ -187,6 +225,24 @@ chats.openapi(listChats, async (c) => { webSearch: tables.chat.webSearch, shareId: tables.chatShare.id, sharedAt: tables.chatShare.createdAt, + orgShareId: sql`( + SELECT ${tables.chatShare.id} + FROM ${tables.chatShare} + WHERE ${tables.chatShare.chatId} = ${tables.chat.id} + AND ${tables.chatShare.organizationId} IS NOT NULL + AND ${tables.chatShare.deletedAt} IS NULL + ORDER BY ${tables.chatShare.createdAt} DESC + LIMIT 1 + )`, + orgShareOrganizationId: sql`( + SELECT ${tables.chatShare.organizationId} + FROM ${tables.chatShare} + WHERE ${tables.chatShare.chatId} = ${tables.chat.id} + AND ${tables.chatShare.organizationId} IS NOT NULL + AND ${tables.chatShare.deletedAt} IS NULL + ORDER BY ${tables.chatShare.createdAt} DESC + LIMIT 1 + )`, createdAt: tables.chat.createdAt, updatedAt: tables.chat.updatedAt, messageCount: count(tables.message.id), @@ -198,6 +254,7 @@ chats.openapi(listChats, async (c) => { and( eq(tables.chat.id, tables.chatShare.chatId), isNull(tables.chatShare.deletedAt), + isNull(tables.chatShare.organizationId), ), ) .where( @@ -224,6 +281,8 @@ chats.openapi(listChats, async (c) => { webSearch: chat.webSearch ?? false, shareId: chat.shareId, sharedAt: chat.sharedAt?.toISOString() ?? null, + orgShareId: chat.orgShareId, + orgShareOrganizationId: chat.orgShareOrganizationId, createdAt: chat.createdAt.toISOString(), updatedAt: chat.updatedAt.toISOString(), messageCount: chat.messageCount, @@ -300,6 +359,24 @@ chats.openapi(searchChats, async (c) => { webSearch: tables.chat.webSearch, shareId: tables.chatShare.id, sharedAt: tables.chatShare.createdAt, + orgShareId: sql`( + SELECT ${tables.chatShare.id} + FROM ${tables.chatShare} + WHERE ${tables.chatShare.chatId} = ${tables.chat.id} + AND ${tables.chatShare.organizationId} IS NOT NULL + AND ${tables.chatShare.deletedAt} IS NULL + ORDER BY ${tables.chatShare.createdAt} DESC + LIMIT 1 + )`, + orgShareOrganizationId: sql`( + SELECT ${tables.chatShare.organizationId} + FROM ${tables.chatShare} + WHERE ${tables.chatShare.chatId} = ${tables.chat.id} + AND ${tables.chatShare.organizationId} IS NOT NULL + AND ${tables.chatShare.deletedAt} IS NULL + ORDER BY ${tables.chatShare.createdAt} DESC + LIMIT 1 + )`, createdAt: tables.chat.createdAt, updatedAt: tables.chat.updatedAt, messageCount: count(tables.message.id), @@ -311,6 +388,7 @@ chats.openapi(searchChats, async (c) => { and( eq(tables.chat.id, tables.chatShare.chatId), isNull(tables.chatShare.deletedAt), + isNull(tables.chatShare.organizationId), ), ) .where(where) @@ -342,6 +420,8 @@ chats.openapi(searchChats, async (c) => { webSearch: chat.webSearch ?? false, shareId: chat.shareId, sharedAt: chat.sharedAt?.toISOString() ?? null, + orgShareId: chat.orgShareId, + orgShareOrganizationId: chat.orgShareOrganizationId, createdAt: chat.createdAt.toISOString(), updatedAt: chat.updatedAt.toISOString(), messageCount: chat.messageCount, @@ -437,6 +517,8 @@ chats.openapi(createChat, async (c) => { webSearch: newChat.webSearch ?? false, shareId: null, sharedAt: null, + orgShareId: null, + orgShareOrganizationId: null, createdAt: newChat.createdAt.toISOString(), updatedAt: newChat.updatedAt.toISOString(), messageCount: 0, @@ -500,6 +582,24 @@ chats.openapi(getChat, async (c) => { updatedAt: tables.chat.updatedAt, shareId: tables.chatShare.id, sharedAt: tables.chatShare.createdAt, + orgShareId: sql`( + SELECT ${tables.chatShare.id} + FROM ${tables.chatShare} + WHERE ${tables.chatShare.chatId} = ${tables.chat.id} + AND ${tables.chatShare.organizationId} IS NOT NULL + AND ${tables.chatShare.deletedAt} IS NULL + ORDER BY ${tables.chatShare.createdAt} DESC + LIMIT 1 + )`, + orgShareOrganizationId: sql`( + SELECT ${tables.chatShare.organizationId} + FROM ${tables.chatShare} + WHERE ${tables.chatShare.chatId} = ${tables.chat.id} + AND ${tables.chatShare.organizationId} IS NOT NULL + AND ${tables.chatShare.deletedAt} IS NULL + ORDER BY ${tables.chatShare.createdAt} DESC + LIMIT 1 + )`, }) .from(tables.chat) .leftJoin( @@ -507,6 +607,7 @@ chats.openapi(getChat, async (c) => { and( eq(tables.chat.id, tables.chatShare.chatId), isNull(tables.chatShare.deletedAt), + isNull(tables.chatShare.organizationId), ), ) .where( @@ -538,6 +639,8 @@ chats.openapi(getChat, async (c) => { webSearch: chat.webSearch ?? false, shareId: chat.shareId, sharedAt: chat.sharedAt?.toISOString() ?? null, + orgShareId: chat.orgShareId, + orgShareOrganizationId: chat.orgShareOrganizationId, createdAt: chat.createdAt.toISOString(), updatedAt: chat.updatedAt.toISOString(), messageCount: messages.length, @@ -629,9 +732,28 @@ chats.openapi(updateChat, async (c) => { }) .from(tables.chatShare) .where( - and(eq(tables.chatShare.chatId, id), isNull(tables.chatShare.deletedAt)), + and( + eq(tables.chatShare.chatId, id), + isNull(tables.chatShare.deletedAt), + isNull(tables.chatShare.organizationId), + ), ) .limit(1); + const [activeOrgShare] = await db + .select({ + id: tables.chatShare.id, + organizationId: tables.chatShare.organizationId, + }) + .from(tables.chatShare) + .where( + and( + eq(tables.chatShare.chatId, id), + isNull(tables.chatShare.deletedAt), + isNotNull(tables.chatShare.organizationId), + ), + ) + .orderBy(desc(tables.chatShare.createdAt)) + .limit(1); return c.json({ chat: { @@ -642,6 +764,8 @@ chats.openapi(updateChat, async (c) => { webSearch: updatedChat.webSearch ?? false, shareId: activeShare?.id ?? null, sharedAt: activeShare?.createdAt.toISOString() ?? null, + orgShareId: activeOrgShare?.id ?? null, + orgShareOrganizationId: activeOrgShare?.organizationId ?? null, createdAt: updatedChat.createdAt.toISOString(), updatedAt: updatedChat.updatedAt.toISOString(), messageCount: messageCount[0].count, @@ -656,6 +780,14 @@ const shareChat = createRoute({ params: z.object({ id: z.string(), }), + body: { + required: false, + content: { + "application/json": { + schema: shareChatSchema, + }, + }, + }, }, responses: { 200: { @@ -678,6 +810,33 @@ chats.openapi(shareChat, async (c) => { } const { id } = c.req.valid("param"); + const body = shareChatSchema.parse(await c.req.json().catch(() => ({}))); + const organizationId = body.organizationId ?? null; + if (organizationId) { + const hasAccess = await userHasOrganizationAccess(user.id, organizationId); + if (!hasAccess) { + throw new HTTPException(404, { message: "Organization not found" }); + } + + const [organization] = await db + .select({ + id: tables.organization.id, + }) + .from(tables.organization) + .where( + and( + eq(tables.organization.id, organizationId), + eq(tables.organization.status, "active"), + eq(tables.organization.isPersonal, false), + ), + ) + .limit(1); + + if (!organization) { + throw new HTTPException(404, { message: "Organization not found" }); + } + } + const [existingShare] = await db .select() .from(tables.chatShare) @@ -685,6 +844,9 @@ chats.openapi(shareChat, async (c) => { and( eq(tables.chatShare.chatId, id), eq(tables.chatShare.userId, user.id), + organizationId + ? eq(tables.chatShare.organizationId, organizationId) + : isNull(tables.chatShare.organizationId), isNull(tables.chatShare.deletedAt), ), ) @@ -694,8 +856,11 @@ chats.openapi(shareChat, async (c) => { return c.json({ share: { id: existingShare.id, - url: `/share/${existingShare.id}`, + url: organizationId + ? `/org/${organizationId}/chat/${existingShare.id}` + : `/share/${existingShare.id}`, createdAt: existingShare.createdAt.toISOString(), + organizationId, }, }); } @@ -737,6 +902,7 @@ chats.openapi(shareChat, async (c) => { .insert(tables.chatShare) .values({ chatId: chat.id, + organizationId, userId: user.id, title: chat.title, model: chat.model, @@ -753,10 +919,7 @@ chats.openapi(shareChat, async (c) => { createdAt: message.createdAt.toISOString(), })), }) - .onConflictDoNothing({ - target: tables.chatShare.chatId, - where: sql`${tables.chatShare.deletedAt} IS NULL`, - }) + .onConflictDoNothing() .returning(); if (!share) { @@ -767,6 +930,9 @@ chats.openapi(shareChat, async (c) => { and( eq(tables.chatShare.chatId, id), eq(tables.chatShare.userId, user.id), + organizationId + ? eq(tables.chatShare.organizationId, organizationId) + : isNull(tables.chatShare.organizationId), isNull(tables.chatShare.deletedAt), ), ) @@ -781,8 +947,11 @@ chats.openapi(shareChat, async (c) => { return c.json({ share: { id: activeShare.id, - url: `/share/${activeShare.id}`, + url: organizationId + ? `/org/${organizationId}/chat/${activeShare.id}` + : `/share/${activeShare.id}`, createdAt: activeShare.createdAt.toISOString(), + organizationId, }, }); } @@ -790,8 +959,11 @@ chats.openapi(shareChat, async (c) => { return c.json({ share: { id: share.id, - url: `/share/${share.id}`, + url: organizationId + ? `/org/${organizationId}/chat/${share.id}` + : `/share/${share.id}`, createdAt: share.createdAt.toISOString(), + organizationId, }, }); }); @@ -835,7 +1007,236 @@ chats.openapi(deleteChatShare, async (c) => { and( eq(tables.chatShare.chatId, id), eq(tables.chatShare.userId, user.id), + isNull(tables.chatShare.organizationId), + isNull(tables.chatShare.deletedAt), + ), + ) + .returning(); + + if (deletedRows.length === 0) { + throw new HTTPException(404, { message: "Share not found" }); + } + + return c.json({ message: "Share deleted successfully" }); +}); + +const listOrgShares = createRoute({ + method: "get", + path: "/org/{organizationId}/shares", + request: { + params: z.object({ + organizationId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + shares: z.array(orgShareListItemSchema), + }), + }, + }, + description: "Organization shared chat snapshots.", + }, + }, +}); + +chats.openapi(listOrgShares, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { organizationId } = c.req.valid("param"); + const hasAccess = await userHasOrganizationAccess(user.id, organizationId); + if (!hasAccess) { + throw new HTTPException(404, { message: "Organization not found" }); + } + + const rows = await db + .select({ + id: tables.chatShare.id, + title: tables.chatShare.title, + model: tables.chatShare.model, + createdAt: tables.chatShare.createdAt, + updatedAt: tables.chatShare.updatedAt, + }) + .from(tables.chatShare) + .innerJoin(tables.chat, eq(tables.chatShare.chatId, tables.chat.id)) + .innerJoin( + tables.organization, + eq(tables.chatShare.organizationId, tables.organization.id), + ) + .where( + and( + eq(tables.chatShare.organizationId, organizationId), + isNull(tables.chatShare.deletedAt), + eq(tables.chat.status, "active"), + eq(tables.organization.status, "active"), + eq(tables.organization.isPersonal, false), + ), + ) + .orderBy(desc(tables.chatShare.updatedAt)); + + return c.json({ + shares: rows.map((share) => ({ + id: share.id, + title: share.title, + model: share.model, + createdAt: share.createdAt.toISOString(), + updatedAt: share.updatedAt.toISOString(), + })), + }); +}); + +const getOrgShare = createRoute({ + method: "get", + path: "/org-share/{shareId}", + request: { + params: z.object({ + shareId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + share: orgShareSchema, + }), + }, + }, + description: "Organization shared chat snapshot.", + }, + }, +}); + +chats.openapi(getOrgShare, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { shareId } = c.req.valid("param"); + const [share] = await db + .select({ + id: tables.chatShare.id, + title: tables.chatShare.title, + model: tables.chatShare.model, + messages: tables.chatShare.messages, + createdAt: tables.chatShare.createdAt, + organizationId: tables.chatShare.organizationId, + }) + .from(tables.chatShare) + .innerJoin(tables.chat, eq(tables.chatShare.chatId, tables.chat.id)) + .innerJoin( + tables.organization, + eq(tables.chatShare.organizationId, tables.organization.id), + ) + .where( + and( + eq(tables.chatShare.id, shareId), + isNull(tables.chatShare.deletedAt), + isNotNull(tables.chatShare.organizationId), + eq(tables.chat.status, "active"), + eq(tables.organization.status, "active"), + eq(tables.organization.isPersonal, false), + ), + ) + .limit(1); + + if (!share?.organizationId) { + throw new HTTPException(404, { message: "Shared chat not found" }); + } + + const hasAccess = await userHasOrganizationAccess( + user.id, + share.organizationId, + ); + if (!hasAccess) { + throw new HTTPException(404, { message: "Shared chat not found" }); + } + + const messages = sharedMessageSnapshotSchema.parse(share.messages); + + return c.json({ + share: { + id: share.id, + title: share.title, + model: share.model, + createdAt: share.createdAt.toISOString(), + messages, + }, + }); +}); + +const deleteOrgShare = createRoute({ + method: "delete", + path: "/org-share/{shareId}", + request: { + params: z.object({ + shareId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + message: z.string(), + }), + }, + }, + description: "Organization share deleted successfully.", + }, + }, +}); + +chats.openapi(deleteOrgShare, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { shareId } = c.req.valid("param"); + const [share] = await db + .select({ + organizationId: tables.chatShare.organizationId, + }) + .from(tables.chatShare) + .where( + and( + eq(tables.chatShare.id, shareId), + isNull(tables.chatShare.deletedAt), + isNotNull(tables.chatShare.organizationId), + ), + ) + .limit(1); + + if (!share?.organizationId) { + throw new HTTPException(404, { message: "Share not found" }); + } + + const hasAccess = await userHasOrganizationAccess( + user.id, + share.organizationId, + ); + if (!hasAccess) { + throw new HTTPException(404, { message: "Share not found" }); + } + + const deletedRows = await db + .update(tables.chatShare) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(tables.chatShare.id, shareId), isNull(tables.chatShare.deletedAt), + isNotNull(tables.chatShare.organizationId), ), ) .returning(); @@ -900,6 +1301,7 @@ chats.openapi(forkSharedChat, async (c) => { title: tables.chatShare.title, model: tables.chatShare.model, messages: tables.chatShare.messages, + organizationId: tables.chatShare.organizationId, }) .from(tables.chatShare) .innerJoin(tables.chat, eq(tables.chatShare.chatId, tables.chat.id)) @@ -916,6 +1318,16 @@ chats.openapi(forkSharedChat, async (c) => { return c.json({ message: "Shared chat not found" }, 404); } + if (share.organizationId) { + const hasAccess = await userHasOrganizationAccess( + user.id, + share.organizationId, + ); + if (!hasAccess) { + return c.json({ message: "Shared chat not found" }, 404); + } + } + await enforceActiveChatLimit(user.id); const messages = sharedMessageSnapshotSchema.parse(share.messages); diff --git a/apps/api/src/routes/public-chat-shares.ts b/apps/api/src/routes/public-chat-shares.ts index 0b3af9b6b5..6b34be54e1 100644 --- a/apps/api/src/routes/public-chat-shares.ts +++ b/apps/api/src/routes/public-chat-shares.ts @@ -96,7 +96,11 @@ publicChatShares.openapi(listSharedChats, async (c) => { .from(tables.chatShare) .innerJoin(tables.chat, eq(tables.chatShare.chatId, tables.chat.id)) .where( - and(isNull(tables.chatShare.deletedAt), eq(tables.chat.status, "active")), + and( + isNull(tables.chatShare.deletedAt), + isNull(tables.chatShare.organizationId), + eq(tables.chat.status, "active"), + ), ) .orderBy(desc(tables.chatShare.updatedAt)) .limit(limit ?? 5000); @@ -128,6 +132,7 @@ publicChatShares.openapi(getSharedChat, async (c) => { and( eq(tables.chatShare.id, shareId), isNull(tables.chatShare.deletedAt), + isNull(tables.chatShare.organizationId), eq(tables.chat.status, "active"), ), ) diff --git a/apps/code/src/lib/api/v1.d.ts b/apps/code/src/lib/api/v1.d.ts index a34e5fea03..7019e0d2a1 100644 --- a/apps/code/src/lib/api/v1.d.ts +++ b/apps/code/src/lib/api/v1.d.ts @@ -7406,6 +7406,8 @@ export interface paths { shareId: string | null; /** Format: date-time */ sharedAt: string | null; + orgShareId: string | null; + orgShareOrganizationId: string | null; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -7453,6 +7455,8 @@ export interface paths { shareId: string | null; /** Format: date-time */ sharedAt: string | null; + orgShareId: string | null; + orgShareOrganizationId: string | null; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -7518,6 +7522,8 @@ export interface paths { shareId: string | null; /** Format: date-time */ sharedAt: string | null; + orgShareId: string | null; + orgShareOrganizationId: string | null; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -7573,6 +7579,8 @@ export interface paths { shareId: string | null; /** Format: date-time */ sharedAt: string | null; + orgShareId: string | null; + orgShareOrganizationId: string | null; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -7675,6 +7683,8 @@ export interface paths { shareId: string | null; /** Format: date-time */ sharedAt: string | null; + orgShareId: string | null; + orgShareOrganizationId: string | null; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -7706,7 +7716,13 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": { + organizationId?: string; + }; + }; + }; responses: { /** @description Chat share snapshot. */ 200: { @@ -7720,6 +7736,7 @@ export interface paths { url: string; /** Format: date-time */ createdAt: string; + organizationId?: string | null; }; }; }; @@ -7755,6 +7772,137 @@ export interface paths { patch?: never; trace?: never; }; + "/chats/org/{organizationId}/shares": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Organization shared chat snapshots. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + shares: { + id: string; + title: string; + model: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }[]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chats/org-share/{shareId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + shareId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Organization shared chat snapshot. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + share: { + id: string; + title: string; + model: string; + /** Format: date-time */ + createdAt: string; + messages: { + id: string; + /** @enum {string} */ + role: "user" | "assistant" | "system"; + content: string | null; + images: string | null; + audios?: string | null; + reasoning: string | null; + tools: string | null; + metadata?: { + [key: string]: unknown; + } | null; + sequence: number; + /** Format: date-time */ + createdAt: string; + }[]; + }; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + shareId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Organization share deleted successfully. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/chats/share/{shareId}/fork": { parameters: { query?: never; diff --git a/apps/playground/src/app/org/[orgId]/chat/[shareId]/page.tsx b/apps/playground/src/app/org/[orgId]/chat/[shareId]/page.tsx new file mode 100644 index 0000000000..03237d2ab5 --- /dev/null +++ b/apps/playground/src/app/org/[orgId]/chat/[shareId]/page.tsx @@ -0,0 +1,25 @@ +import { renderPlaygroundShell } from "@/app/playground-shell"; + +export default async function OrgSharedChatPage({ + params, + searchParams, +}: { + params: Promise<{ orgId: string; shareId: string }>; + searchParams: Promise<{ + orgId?: string; + projectId?: string; + q?: string; + hints?: string; + model?: string; + }>; +}) { + const [{ orgId, shareId }, resolvedSearchParams] = await Promise.all([ + params, + searchParams, + ]); + + return await renderPlaygroundShell({ + searchParams: resolvedSearchParams, + orgShareView: { organizationId: orgId, shareId }, + }); +} diff --git a/apps/playground/src/app/org/[orgId]/page.tsx b/apps/playground/src/app/org/[orgId]/page.tsx new file mode 100644 index 0000000000..8ecdcc0361 --- /dev/null +++ b/apps/playground/src/app/org/[orgId]/page.tsx @@ -0,0 +1,25 @@ +import { renderPlaygroundShell } from "@/app/playground-shell"; + +export default async function OrgSharedChatsPage({ + params, + searchParams, +}: { + params: Promise<{ orgId: string }>; + searchParams: Promise<{ + orgId?: string; + projectId?: string; + q?: string; + hints?: string; + model?: string; + }>; +}) { + const [{ orgId }, resolvedSearchParams] = await Promise.all([ + params, + searchParams, + ]); + + return await renderPlaygroundShell({ + searchParams: resolvedSearchParams, + orgShareView: { organizationId: orgId }, + }); +} diff --git a/apps/playground/src/app/page.tsx b/apps/playground/src/app/page.tsx index 1fd3522b17..c79e100068 100644 --- a/apps/playground/src/app/page.tsx +++ b/apps/playground/src/app/page.tsx @@ -1,12 +1,5 @@ -import { cookies } from "next/headers"; -import { notFound, redirect } from "next/navigation"; +import { renderPlaygroundShell } from "./playground-shell"; -import { LastUsedProjectTracker } from "@/components/last-used-project-tracker"; -import ChatPageClient from "@/components/playground/chat-page-client"; -import { fetchModels, fetchProviders } from "@/lib/fetch-models"; -import { fetchServerData } from "@/lib/server-api"; - -import type { Project, Organization } from "@/lib/types"; import type { Metadata } from "next"; export async function generateMetadata(): Promise { @@ -21,165 +14,17 @@ export async function generateMetadata(): Promise { }; } -export interface GatewayModel { - id: string; - name?: string; - architecture?: { input_modalities?: string[] }; -} - export default async function ChatPage({ searchParams, }: { searchParams: Promise<{ - orgId: string; - projectId: string; + orgId?: string; + projectId?: string; q?: string; hints?: string; model?: string; }>; }) { const params = await searchParams; - const { orgId, projectId, q, hints } = params; - let { model } = params; - - // Auto-select a web search capable model when hints=search - if (hints === "search" && !model) { - model = "google-ai-studio/gemini-3-flash-preview"; - // Redirect to add the model parameter to the URL - const newParams = new URLSearchParams(); - if (orgId) { - newParams.set("orgId", orgId); - } - if (projectId) { - newParams.set("projectId", projectId); - } - if (q) { - newParams.set("q", q); - } - if (hints) { - newParams.set("hints", hints); - } - newParams.set("model", model); - redirect(`/?${newParams.toString()}`); - } - - // Fetch organizations server-side - const initialOrganizationsData = await fetchServerData("GET", "/orgs"); - - // Fetch projects for the specific organization (if provided) - let initialProjectsData: { projects: Project[] } | null = null; - if (orgId) { - try { - initialProjectsData = (await fetchServerData( - "GET", - "/orgs/{id}/projects", - { - params: { - path: { - id: orgId, - }, - }, - }, - )) as { projects: Project[] }; - } catch (error) { - console.warn("Failed to fetch projects for organization:", orgId, error); - } - } - - // Validate that the project exists and is not deleted (if explicitly provided) - if ( - projectId && - initialProjectsData && - typeof initialProjectsData === "object" && - "projects" in initialProjectsData - ) { - const projects = (initialProjectsData as { projects: Project[] }).projects; - const currentProject = projects.find((p: Project) => p.id === projectId); - - // If project is not found in the active projects list, it's either deleted or doesn't exist - if (!currentProject) { - notFound(); - } - } - - const organizations = ( - initialOrganizationsData && - typeof initialOrganizationsData === "object" && - "organizations" in initialOrganizationsData - ? (initialOrganizationsData as { organizations: Organization[] }) - .organizations - : [] - ) as Organization[]; - const selectedOrganization = - (orgId ? organizations.find((o) => o.id === orgId) : organizations[0]) ?? - null; - - // Ensure we have projects for the selected organization (when orgId not provided) - if (!initialProjectsData && selectedOrganization?.id) { - try { - initialProjectsData = (await fetchServerData( - "GET", - "/orgs/{id}/projects", - { - params: { - path: { - id: selectedOrganization.id, - }, - }, - }, - )) as { projects: Project[] }; - } catch (error) { - console.warn( - "Failed to fetch projects for organization:", - selectedOrganization?.id, - error, - ); - } - } - - const projects = (initialProjectsData?.projects ?? []) as Project[]; - - // Fetch models and providers from API - const [models, providers] = await Promise.all([ - fetchModels(), - fetchProviders(), - ]); - - // Determine selected project: URL > cookie > first - let selectedProject: Project | null = null; - if (projectId) { - selectedProject = projects.find((p) => p.id === projectId) ?? null; - if (projectId && !selectedProject && projectId.length > 0) { - notFound(); - } - } else if (selectedOrganization?.id) { - const cookieStore = await cookies(); - const cookieName = `llmgateway-last-used-project-${selectedOrganization.id}`; - const lastUsed = cookieStore.get(cookieName)?.value; - if (lastUsed) { - selectedProject = projects.find((p) => p.id === lastUsed) ?? null; - } - } - selectedProject ??= projects[0] ?? null; - - return ( - <> - {selectedOrganization?.id && selectedProject?.id ? ( - - ) : null} - !m.output?.includes("video"))} - providers={providers} - organizations={organizations} - selectedOrganization={selectedOrganization} - projects={projects} - selectedProject={selectedProject} - initialPrompt={q} - enableWebSearch={hints === "search"} - /> - - ); + return await renderPlaygroundShell({ searchParams: params }); } diff --git a/apps/playground/src/app/playground-shell.tsx b/apps/playground/src/app/playground-shell.tsx new file mode 100644 index 0000000000..3905644b00 --- /dev/null +++ b/apps/playground/src/app/playground-shell.tsx @@ -0,0 +1,192 @@ +import { cookies } from "next/headers"; +import { notFound, redirect } from "next/navigation"; + +import { LastUsedProjectTracker } from "@/components/last-used-project-tracker"; +import ChatPageClient from "@/components/playground/chat-page-client"; +import OrgPageClient from "@/components/playground/org-page-client"; +import { fetchModels, fetchProviders } from "@/lib/fetch-models"; +import { fetchServerData } from "@/lib/server-api"; + +import type { Organization, Project } from "@/lib/types"; + +export interface GatewayModel { + id: string; + name?: string; + architecture?: { input_modalities?: string[] }; +} + +export interface PlaygroundSearchParams { + orgId?: string; + projectId?: string; + q?: string; + hints?: string; + model?: string; +} + +export interface OrgShareView { + organizationId: string; + shareId?: string; +} + +interface RenderPlaygroundShellOptions { + searchParams: PlaygroundSearchParams; + orgShareView?: OrgShareView; +} + +export async function renderPlaygroundShell({ + searchParams, + orgShareView, +}: RenderPlaygroundShellOptions) { + const { q, hints } = searchParams; + const orgId = orgShareView?.organizationId ?? searchParams.orgId; + const { projectId } = searchParams; + let { model } = searchParams; + + if (hints === "search" && !model) { + model = "google-ai-studio/gemini-3-flash-preview"; + const newParams = new URLSearchParams(); + if (orgId) { + newParams.set("orgId", orgId); + } + if (projectId) { + newParams.set("projectId", projectId); + } + if (q) { + newParams.set("q", q); + } + if (hints) { + newParams.set("hints", hints); + } + newParams.set("model", model); + redirect(`/?${newParams.toString()}`); + } + + const initialOrganizationsData = await fetchServerData("GET", "/orgs"); + const organizations = ( + initialOrganizationsData && + typeof initialOrganizationsData === "object" && + "organizations" in initialOrganizationsData + ? (initialOrganizationsData as { organizations: Organization[] }) + .organizations + : [] + ) as Organization[]; + + if ( + orgShareView && + !organizations.some((org) => org.id === orgShareView.organizationId) + ) { + notFound(); + } + + let initialProjectsData: { projects: Project[] } | null = null; + if (orgId) { + try { + initialProjectsData = (await fetchServerData( + "GET", + "/orgs/{id}/projects", + { + params: { + path: { + id: orgId, + }, + }, + }, + )) as { projects: Project[] }; + } catch (error) { + console.warn("Failed to fetch projects for organization:", orgId, error); + } + } + + if ( + projectId && + initialProjectsData && + typeof initialProjectsData === "object" && + "projects" in initialProjectsData + ) { + const projects = (initialProjectsData as { projects: Project[] }).projects; + const currentProject = projects.find((p: Project) => p.id === projectId); + + if (!currentProject) { + notFound(); + } + } + + const selectedOrganization = + (orgId ? organizations.find((o) => o.id === orgId) : null) ?? null; + + if (!initialProjectsData && selectedOrganization?.id) { + try { + initialProjectsData = (await fetchServerData( + "GET", + "/orgs/{id}/projects", + { + params: { + path: { + id: selectedOrganization.id, + }, + }, + }, + )) as { projects: Project[] }; + } catch (error) { + console.warn( + "Failed to fetch projects for organization:", + selectedOrganization?.id, + error, + ); + } + } + + const projects = (initialProjectsData?.projects ?? []) as Project[]; + const [models, providers] = await Promise.all([ + fetchModels(), + fetchProviders(), + ]); + + let selectedProject: Project | null = null; + if (projectId) { + selectedProject = projects.find((p) => p.id === projectId) ?? null; + if (projectId && !selectedProject && projectId.length > 0) { + notFound(); + } + } else if (selectedOrganization?.id) { + const cookieStore = await cookies(); + const cookieName = `llmgateway-last-used-project-${selectedOrganization.id}`; + const lastUsed = cookieStore.get(cookieName)?.value; + if (lastUsed) { + selectedProject = projects.find((p) => p.id === lastUsed) ?? null; + } + } + selectedProject ??= projects[0] ?? null; + + if (orgShareView) { + return ( + + ); + } + + return ( + <> + {selectedOrganization?.id && selectedProject?.id ? ( + + ) : null} + !m.output?.includes("video"))} + providers={providers} + organizations={organizations} + selectedOrganization={selectedOrganization} + projects={projects} + selectedProject={selectedProject} + initialPrompt={q} + enableWebSearch={hints === "search"} + /> + + ); +} diff --git a/apps/playground/src/components/playground/chat-header.tsx b/apps/playground/src/components/playground/chat-header.tsx index 0b5d6a1b4c..f0350bb811 100644 --- a/apps/playground/src/components/playground/chat-header.tsx +++ b/apps/playground/src/components/playground/chat-header.tsx @@ -11,6 +11,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import type { McpServer } from "@/hooks/useMcpServers"; import type { ApiModel, ApiProvider } from "@/lib/fetch-models"; +import type { Organization } from "@/lib/types"; interface ChatHeaderProps { models: ApiModel[]; @@ -31,10 +32,15 @@ interface ChatHeaderProps { onToggleMcpServer: (id: string) => void; isTemporaryChat: boolean; onToggleTemporaryChat: () => void; + showTemporaryChatSwitcher: boolean; isTemporaryChatToggleDisabled: boolean; hasTemporaryMessages: boolean; currentChatId: string | null; + isShareChatDisabled?: boolean; shareId: string | null; + orgShareId: string | null; + orgShareOrganizationId: string | null; + organizations: Organization[]; chatTitle?: string | null; previewPrompt?: string | null; } @@ -54,10 +60,15 @@ export const ChatHeader = ({ onToggleMcpServer, isTemporaryChat, onToggleTemporaryChat, + showTemporaryChatSwitcher, isTemporaryChatToggleDisabled, hasTemporaryMessages, currentChatId, + isShareChatDisabled = true, shareId, + orgShareId, + orgShareOrganizationId, + organizations, chatTitle, previewPrompt, }: ChatHeaderProps) => { @@ -78,16 +89,22 @@ export const ChatHeader = ({ ) : null}
- + {showTemporaryChatSwitcher ? ( + + ) : null} {isTemporaryChat || !currentChatId ? null : ( diff --git a/apps/playground/src/components/playground/chat-page-client.tsx b/apps/playground/src/components/playground/chat-page-client.tsx index 40702d99b9..0ca62fee48 100644 --- a/apps/playground/src/components/playground/chat-page-client.tsx +++ b/apps/playground/src/components/playground/chat-page-client.tsx @@ -265,7 +265,6 @@ export default function ChatPageClient({ const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const mapped = useMemo( () => mapModels(models, providers), [models, providers], @@ -1194,9 +1193,13 @@ export default function ChatPageClient({ // Remove chat param from URL const params = new URLSearchParams(searchParams.toString()); params.delete("chat"); + params.delete("view"); + params.delete("shareOrgId"); + params.delete("shareId"); + const targetPathname = pathname; const newUrl = params.toString() - ? `${pathname}?${params.toString()}` - : pathname; + ? `${targetPathname}?${params.toString()}` + : targetPathname; router.push(newUrl); }; @@ -1227,9 +1230,13 @@ export default function ChatPageClient({ // Remove chat param from URL const params = new URLSearchParams(searchParams.toString()); params.delete("chat"); + params.delete("view"); + params.delete("shareOrgId"); + params.delete("shareId"); + const targetPathname = pathname; const newUrl = params.toString() - ? `${pathname}?${params.toString()}` - : pathname; + ? `${targetPathname}?${params.toString()}` + : targetPathname; router.push(newUrl); // Clear comparison windows as well setComparisonResetToken((token) => token + 1); @@ -1249,7 +1256,15 @@ export default function ChatPageClient({ // Update URL with chat ID - this will trigger the useEffect to update state const params = new URLSearchParams(searchParams.toString()); params.set("chat", chatId); - router.push(`${pathname}?${params.toString()}`); + params.delete("view"); + params.delete("shareOrgId"); + params.delete("shareId"); + const targetPathname = pathname; + router.push( + params.toString() + ? `${targetPathname}?${params.toString()}` + : targetPathname, + ); }; const handleForkChat = useCallback(async () => { @@ -1284,7 +1299,15 @@ export default function ChatPageClient({ const params = new URLSearchParams(searchParams.toString()); params.set("chat", newChatId); - router.push(`${pathname}?${params.toString()}`); + params.delete("view"); + params.delete("shareOrgId"); + params.delete("shareId"); + const targetPathname = pathname; + router.push( + params.toString() + ? `${targetPathname}?${params.toString()}` + : targetPathname, + ); sidebarRef.current?.scrollToTop(); toast.success("Chat forked"); } catch {} @@ -1350,29 +1373,15 @@ export default function ChatPageClient({ }, [selectedModel]); const handleSelectOrganization = (org: Organization | null) => { - const params = new URLSearchParams(Array.from(searchParams.entries())); if (org?.id) { - params.set("orgId", org.id); - } else { - params.delete("orgId"); - } - // Clear projectId to avoid 404 when switching orgs (server will pick first/last-used) - params.delete("projectId"); - // Always keep model param - if (!params.get("model")) { - params.set("model", selectedModel); + router.push(`/org/${org.id}`); + return; } - router.push(params.toString() ? `/?${params.toString()}` : "/"); + router.push("/"); }; const handleOrganizationCreated = (org: Organization) => { - const params = new URLSearchParams(Array.from(searchParams.entries())); - params.set("orgId", org.id); - params.delete("projectId"); - if (!params.get("model")) { - params.set("model", selectedModel); - } - router.push(params.toString() ? `/?${params.toString()}` : "/"); + router.push(`/org/${org.id}`); }; const handleSelectProject = (project: Project | null) => { @@ -1448,12 +1457,23 @@ export default function ChatPageClient({ onToggleMcpServer={toggleMcpServer} isTemporaryChat={isTemporaryChat} onToggleTemporaryChat={handleToggleTemporaryChat} + showTemporaryChatSwitcher={!currentChatId} isTemporaryChatToggleDisabled={ isLoading || status === "submitted" || status === "streaming" } hasTemporaryMessages={hasTemporaryMessages} currentChatId={currentChatId} + isShareChatDisabled={ + isChatLoading || + status === "submitted" || + status === "streaming" + } shareId={currentChatData?.chat?.shareId ?? null} + orgShareId={currentChatData?.chat?.orgShareId ?? null} + orgShareOrganizationId={ + currentChatData?.chat?.orgShareOrganizationId ?? null + } + organizations={organizations} chatTitle={currentChatData?.chat?.title ?? null} previewPrompt={getFirstUserMessageText(messages)} /> @@ -1658,7 +1678,6 @@ export default function ChatPageClient({ ); } - interface ExtraChatPanelProps { panelIndex: number; models: ApiModel[]; diff --git a/apps/playground/src/components/playground/chat-sidebar.tsx b/apps/playground/src/components/playground/chat-sidebar.tsx index 2ffb39b5f4..acf53d5768 100644 --- a/apps/playground/src/components/playground/chat-sidebar.tsx +++ b/apps/playground/src/components/playground/chat-sidebar.tsx @@ -70,6 +70,7 @@ import { useAuth } from "@/lib/auth-client"; import { ChatSearchDialog } from "./chat-search-dialog"; import { ChatSidebarSkeleton } from "./chat-sidebar-skeleton"; +import { OrganizationSwitcher } from "./organization-switcher"; // import { ProjectSwitcher } from "./project-switcher"; import type { Organization, Project } from "@/lib/types"; @@ -373,7 +374,9 @@ export const ChatSidebar = function ChatSidebar({ clearMessages, className, isLoading: isPageLoading = false, + organizations, selectedOrganization, + onSelectOrganization, }: ChatSidebarProps & { ref?: React.RefObject }) { const listContainerRef = useRef(null); @@ -390,6 +393,7 @@ export const ChatSidebar = function ChatSidebar({ const pathname = usePathname(); const posthog = usePostHog(); const { state: sidebarState, isMobile } = useSidebar(); + const showOrganizationSwitcher = pathname === "/"; const { user, isLoading: isUserLoading } = useUser(); const { signOut } = useAuth(); const { organization, isLoading: isOrgLoading } = useOrganization(); @@ -410,7 +414,7 @@ export const ChatSidebar = function ChatSidebar({ const [isSearchOpen, setIsSearchOpen] = useState(false); const [isMac, setIsMac] = useState(false); - const chats = chatsData?.chats ?? []; + const chats = useMemo(() => chatsData?.chats ?? [], [chatsData?.chats]); const currentTheme = theme === "system" ? systemTheme : theme; const toggleTheme = useCallback(() => { setTheme(currentTheme === "dark" ? "light" : "dark"); @@ -726,7 +730,7 @@ export const ChatSidebar = function ChatSidebar({ - + {/* */}
-
- +
+ {organizations.length > 0 && showOrganizationSwitcher ? ( + <> + + + + + + + ) : null} + +
+ + ))} + + )} +
+ ); +} diff --git a/apps/playground/src/components/playground/org-search-dialog.tsx b/apps/playground/src/components/playground/org-search-dialog.tsx new file mode 100644 index 0000000000..ca4187b993 --- /dev/null +++ b/apps/playground/src/components/playground/org-search-dialog.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { format } from "date-fns"; +import { Loader2, MessageSquare, Search, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { List, type RowComponentProps } from "react-window"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useOrgShares } from "@/hooks/useChats"; +import { cn } from "@/lib/utils"; + +const ROW_HEIGHT_HEADER = 38; +const ROW_HEIGHT_SHARE = 44; +const ROW_HEIGHT_EMPTY = 180; +const ROW_HEIGHT_LOADER = 48; + +const CLOSE_ANIMATION_MS = 150; + +interface OrgShareItem { + id: string; + title: string; + model: string; + createdAt: string; + updatedAt: string; +} + +type SearchRow = + | { type: "header"; key: string; title: string } + | { type: "share"; key: string; share: OrgShareItem } + | { type: "empty"; key: string; message: string } + | { type: "loader"; key: string; message: string }; + +interface OrgSearchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onShareSelect?: (shareId: string) => void; + organizationId: string; +} + +interface SearchRowProps { + rows: SearchRow[]; + onShareSelect: (shareId: string) => void; +} + +function useDebouncedValue(value: string, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeout = window.setTimeout(() => setDebouncedValue(value), delay); + return () => window.clearTimeout(timeout); + }, [delay, value]); + + return debouncedValue; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60); + + if (diffInHours < 1) { + return "Just now"; + } + if (diffInHours < 24) { + return `${Math.floor(diffInHours)}h ago`; + } + if (diffInHours < 48) { + return "Yesterday"; + } + return format(date, "MMM d"); +} + +function groupSharesByDate(shares: OrgShareItem[], now: Date) { + const today = new Date(now); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const lastWeek = new Date(now); + lastWeek.setDate(lastWeek.getDate() - 7); + + const groups = { + today: [] as OrgShareItem[], + yesterday: [] as OrgShareItem[], + lastWeek: [] as OrgShareItem[], + older: [] as OrgShareItem[], + }; + + shares.forEach((share) => { + const shareDate = new Date(share.updatedAt); + if (shareDate.toDateString() === today.toDateString()) { + groups.today.push(share); + } else if (shareDate.toDateString() === yesterday.toDateString()) { + groups.yesterday.push(share); + } else if (shareDate >= lastWeek) { + groups.lastWeek.push(share); + } else { + groups.older.push(share); + } + }); + + return groups; +} + +function getSearchRowHeight(index: number, { rows }: SearchRowProps) { + const row = rows[index]; + if (row?.type === "header") { + return ROW_HEIGHT_HEADER; + } + if (row?.type === "loader") { + return ROW_HEIGHT_LOADER; + } + if (row?.type === "empty") { + return ROW_HEIGHT_EMPTY; + } + return ROW_HEIGHT_SHARE; +} + +function SearchRowComponent({ + ariaAttributes, + index, + style, + rows, + onShareSelect, +}: RowComponentProps) { + const row = rows[index]; + + if (!row) { + return null; + } + + if (row.type === "header") { + return ( +
+
+ {row.title} +
+
+ ); + } + + if (row.type === "loader") { + return ( +
+
+ + {row.message} +
+
+ ); + } + + if (row.type === "empty") { + return ( +
+
+ + {row.message} +
+
+ ); + } + + return ( +
+
+ +
+
+ ); +} + +export function OrgSearchDialog({ + open, + onOpenChange, + onShareSelect, + organizationId, +}: OrgSearchDialogProps) { + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const debouncedQuery = useDebouncedValue(query, 250); + const nowRef = useRef(new Date()); + + const { data: orgSharesData, isLoading } = useOrgShares(organizationId); + + useEffect(() => { + if (open) { + nowRef.current = new Date(); + } else { + setQuery(""); + } + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + const timeout = window.setTimeout(() => inputRef.current?.focus(), 0); + return () => window.clearTimeout(timeout); + }, [open]); + + const shares = useMemo(() => { + const all = orgSharesData?.shares ?? []; + const q = debouncedQuery.trim().toLowerCase(); + return q ? all.filter((s) => s.title.toLowerCase().includes(q)) : all; + }, [orgSharesData?.shares, debouncedQuery]); + + const rows = useMemo(() => { + const groups = groupSharesByDate(shares, nowRef.current); + const nextRows: SearchRow[] = []; + + [ + { title: "Today", shares: groups.today }, + { title: "Yesterday", shares: groups.yesterday }, + { title: "Previous 7 Days", shares: groups.lastWeek }, + { title: "Older", shares: groups.older }, + ].forEach(({ title, shares: groupedShares }) => { + if (groupedShares.length === 0) { + return; + } + nextRows.push({ type: "header", key: `header-${title}`, title }); + groupedShares.forEach((share) => { + nextRows.push({ type: "share", key: `share-${share.id}`, share }); + }); + }); + + if (isLoading) { + nextRows.push({ + type: "loader", + key: "loader", + message: "Loading shared chats...", + }); + } else if (shares.length === 0) { + nextRows.push({ + type: "empty", + key: "empty", + message: debouncedQuery.trim() + ? "No matching chats" + : "No shared chats in this organization", + }); + } + + return nextRows; + }, [shares, debouncedQuery, isLoading]); + + const closeAndSelectShare = useCallback( + (shareId: string) => { + onOpenChange(false); + setTimeout(() => { + onShareSelect?.(shareId); + }, CLOSE_ANIMATION_MS); + }, + [onShareSelect, onOpenChange], + ); + + const rowProps = useMemo( + () => ({ + rows, + onShareSelect: closeAndSelectShare, + }), + [closeAndSelectShare, rows], + ); + + return ( + + + Search organization chats + + Search through shared chats in this organization. + +
+ + setQuery(event.target.value)} + placeholder="Search shared chats..." + className="h-full border-0 bg-transparent! px-0 text-base shadow-none focus-visible:ring-0" + /> + +
+
+ +
+
+
+ ); +} diff --git a/apps/playground/src/components/playground/org-sidebar.tsx b/apps/playground/src/components/playground/org-sidebar.tsx new file mode 100644 index 0000000000..1b48b72b8d --- /dev/null +++ b/apps/playground/src/components/playground/org-sidebar.tsx @@ -0,0 +1,579 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { + MessageSquare, + ChevronUp, + LogOut, + ExternalLink, + Search, + Plus, + Users, + ImagePlus, + Film, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { usePostHog } from "posthog-js/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { List, type RowComponentProps } from "react-window"; + +import { CreditsDisplay } from "@/components/credits/credits-display"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Logo } from "@/components/ui/logo"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; +import { useOrgShares } from "@/hooks/useChats"; +import { useOrganization } from "@/hooks/useOrganization"; +import { useUser } from "@/hooks/useUser"; +import { clearLastUsedProjectCookiesAction } from "@/lib/actions/project"; +import { useAuth } from "@/lib/auth-client"; + +import { ChatSidebarSkeleton } from "./chat-sidebar-skeleton"; +import { OrgSearchDialog } from "./org-search-dialog"; +import { OrganizationSwitcher } from "./organization-switcher"; + +import type { Organization } from "@/lib/types"; + +interface OrgSidebarProps { + organizationId: string; + currentShareId?: string; + organizations: Organization[]; + selectedOrganization: Organization | null; + onSelectOrganization: (org: Organization | null) => void; + className?: string; +} + +interface OrgShareItem { + id: string; + title: string; + model: string; + createdAt: string; + updatedAt: string; +} + +type OrgShareHistoryRow = + | { type: "header"; key: string; title: string } + | { type: "share"; key: string; share: OrgShareItem } + | { type: "spacer"; key: string }; + +const ROW_HEIGHT_HEADER = 32; +const ROW_HEIGHT_SPACER = 16; +const ROW_HEIGHT_SHARE = 60; + +interface OrgShareHistoryRowProps { + rows: OrgShareHistoryRow[]; + currentShareId?: string; + onShareSelect: (shareId: string) => void; +} + +function getOrgShareRowHeight( + index: number, + { rows }: OrgShareHistoryRowProps, +): number { + const row = rows[index]; + if (row?.type === "header") { + return ROW_HEIGHT_HEADER; + } + if (row?.type === "spacer") { + return ROW_HEIGHT_SPACER; + } + return ROW_HEIGHT_SHARE; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60); + + if (diffInHours < 1) { + return "Just now"; + } else if (diffInHours < 24) { + return `${Math.floor(diffInHours)}h ago`; + } else if (diffInHours < 48) { + return "Yesterday"; + } else { + return format(date, "MMM d"); + } +} + +function groupSharesByDate(shares: OrgShareItem[]) { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const lastWeek = new Date(today); + lastWeek.setDate(lastWeek.getDate() - 7); + + const groups = { + today: [] as OrgShareItem[], + yesterday: [] as OrgShareItem[], + lastWeek: [] as OrgShareItem[], + older: [] as OrgShareItem[], + }; + + shares.forEach((share) => { + const shareDate = new Date(share.updatedAt); + if (shareDate.toDateString() === today.toDateString()) { + groups.today.push(share); + } else if (shareDate.toDateString() === yesterday.toDateString()) { + groups.yesterday.push(share); + } else if (shareDate >= lastWeek) { + groups.lastWeek.push(share); + } else { + groups.older.push(share); + } + }); + + return groups; +} + +function OrgShareRowComponent({ + ariaAttributes, + index, + style, + rows, + currentShareId, + onShareSelect, +}: RowComponentProps) { + const row = rows[index]; + + if (!row) { + return null; + } + + if (row.type === "spacer") { + return