From e8a628ac344a4ba857c9e045b392edbe4d10f9e3 Mon Sep 17 00:00:00 2001 From: how2ice Date: Wed, 27 May 2026 14:40:33 +0900 Subject: [PATCH] chore: test deploy snapshot --- etc/servers/work-server/src/routes/chat.ts | 731 ++++++- .../services/chat-share-room-map-service.ts | 312 +++ src/app/main/mainChatPanel/chatUtils.ts | 141 +- src/app/main/pages/ChatSharePage.css | 215 ++- src/app/main/pages/ChatSharePage.tsx | 1699 +++++++++++++---- 5 files changed, 2637 insertions(+), 461 deletions(-) create mode 100644 etc/servers/work-server/src/services/chat-share-room-map-service.ts diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index 8d2c39c..84de99a 100644 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -49,6 +49,13 @@ import { updateChatConversationContext, hasPendingAttentionVerificationRequest, } from '../services/chat-room-service.js'; +import { + ensureDefaultChatShareTokenRoomMap, + getChatShareTokenRoomMap, + resolveChatShareTokenRoomSessionIds, + upsertChatShareTokenRoomMap, + type ChatShareTokenRoomMapItem, +} from '../services/chat-share-room-map-service.js'; import { chatRuntimeService } from '../services/chat-runtime-service.js'; import { resolveMainProjectRoot } from '../services/main-project-root-service.js'; import { openResourceManagerPreviewStream } from '../services/resource-manager-service.js'; @@ -228,6 +235,121 @@ function resolveChatShareTokenSettingSnapshot(tokenPayload: ChatShareTokenPayloa }; } +type ChatShareResolvedRoom = { + sessionId: string; + requestId: string; + isDefault: boolean; + sortOrder: number; + title: string; + requestBadgeLabel: string | null; + chatTypeId: string | null; + lastChatTypeId: string | null; + contextLabel: string | null; + contextDescription: string | null; + notifyOffline: boolean; + createdAt: string | null; + updatedAt: string | null; +}; + +function mapResolvedShareRoomItem(room: ChatShareTokenRoomMapItem): ChatShareResolvedRoom { + return { + sessionId: room.sessionId, + requestId: room.rootRequestId, + isDefault: room.isDefault, + sortOrder: room.sortOrder, + title: room.title, + requestBadgeLabel: room.requestBadgeLabel, + chatTypeId: room.chatTypeId, + lastChatTypeId: room.lastChatTypeId, + contextLabel: room.contextLabel, + contextDescription: room.contextDescription, + notifyOffline: room.notifyOffline, + createdAt: room.createdAt, + updatedAt: room.conversationUpdatedAt ?? room.updatedAt, + }; +} + +async function resolveManagedShareRooms(args: { + managedResource: + | Awaited>['managedResource'] + | null; + tokenPayload: ChatShareTokenPayload; +}) { + const managedResourceTokenId = args.managedResource?.token.id?.trim() || ''; + + if (!managedResourceTokenId || args.tokenPayload.kind !== 'request-bundle') { + return [ + { + sessionId: args.tokenPayload.sessionId, + requestId: args.tokenPayload.requestId, + isDefault: true, + sortOrder: 0, + title: '공유 채팅방', + requestBadgeLabel: null, + chatTypeId: null, + lastChatTypeId: null, + contextLabel: null, + contextDescription: null, + notifyOffline: false, + createdAt: null, + updatedAt: null, + } satisfies ChatShareResolvedRoom, + ]; + } + + const ensuredRooms = await ensureDefaultChatShareTokenRoomMap({ + tokenId: managedResourceTokenId, + sessionId: args.tokenPayload.sessionId, + rootRequestId: args.tokenPayload.requestId, + createdByClientId: null, + }); + + if (ensuredRooms.length === 0) { + return [ + { + sessionId: args.tokenPayload.sessionId, + requestId: args.tokenPayload.requestId, + isDefault: true, + sortOrder: 0, + title: '공유 채팅방', + requestBadgeLabel: null, + chatTypeId: null, + lastChatTypeId: null, + contextLabel: null, + contextDescription: null, + notifyOffline: false, + createdAt: null, + updatedAt: null, + } satisfies ChatShareResolvedRoom, + ]; + } + + return ensuredRooms.map((room) => mapResolvedShareRoomItem(room)); +} + +async function resolveActiveManagedShareRoom(args: { + managedResource: + | Awaited>['managedResource'] + | null; + tokenPayload: ChatShareTokenPayload; + requestedSessionId?: string | null; +}) { + const rooms = await resolveManagedShareRooms({ + managedResource: args.managedResource, + tokenPayload: args.tokenPayload, + }); + const requestedSessionId = args.requestedSessionId?.trim() || ''; + const requestedRoom = requestedSessionId ? rooms.find((room) => room.sessionId === requestedSessionId) ?? null : null; + const defaultRoom = rooms.find((room) => room.isDefault) ?? rooms[0] ?? null; + const activeRoom = requestedRoom ?? defaultRoom; + + return { + rooms, + activeRoom, + requestedRoomMatched: !requestedSessionId || Boolean(requestedRoom), + }; +} + function createManagedChatShareTokenId() { return `chat_share_${randomUUID().replace(/-/g, '').slice(0, 20)}`; } @@ -991,8 +1113,14 @@ function buildManagedSharePlaceholderRequest(tokenPayload: ChatShareTokenPayload } satisfies ListedChatConversationRequest; } -async function buildChatShareSnapshot(tokenPayload: ChatShareTokenPayload) { - const normalizedSessionId = tokenPayload.sessionId.trim(); +async function buildChatShareSnapshot( + tokenPayload: ChatShareTokenPayload, + options?: { + sessionId?: string | null; + requestId?: string | null; + }, +) { + const normalizedSessionId = options?.sessionId?.trim() || tokenPayload.sessionId.trim(); const conversation = await getChatConversation(normalizedSessionId, null); if (!conversation) { @@ -1004,7 +1132,7 @@ async function buildChatShareSnapshot(tokenPayload: ChatShareTokenPayload) { listChatConversationMessages(normalizedSessionId, { limit: 1000 }), ]); const requestMap = new Map(requests.map((request) => [request.requestId.trim(), request] as const)); - const targetRequestId = tokenPayload.requestId.trim(); + const targetRequestId = options?.requestId?.trim() || tokenPayload.requestId.trim(); const targetRequestFromStore = requestMap.get(targetRequestId) ?? null; const placeholderTargetRequest = targetRequestFromStore ? null : buildManagedSharePlaceholderRequest(tokenPayload, conversation); const targetRequest = targetRequestFromStore ?? placeholderTargetRequest; @@ -1185,6 +1313,28 @@ async function resolveManagedChatShareContext(token: string) { }; } +function buildManagedShareRoomRuntimeSnapshot( + snapshot: ReturnType, + sessionId: string, +) { + const normalizedSessionId = sessionId.trim(); + const running = snapshot.running.filter((item) => item.sessionId === normalizedSessionId); + const queued = snapshot.queued.filter((item) => item.sessionId === normalizedSessionId); + const sessions = snapshot.sessions.filter((item) => item.sessionId === normalizedSessionId); + const recent = snapshot.recent.filter((item) => item.sessionId === normalizedSessionId); + + return { + generatedAt: snapshot.generatedAt, + runningCount: running.length, + queuedCount: queued.length, + sessionCount: sessions.length, + running, + queued, + sessions, + recent, + }; +} + function resolveChatSharePayloadFromManagedResource( managedResource: | Awaited> @@ -1402,8 +1552,13 @@ export async function registerChatRoutes(app: FastifyInstance) { } const requestedRelativePath = normalizeChatResourceWildcard(wildcard); - const allowedSessionPrefix = path.posix.join('.codex_chat', tokenPayload.sessionId, 'resource') + '/'; - const isAllowedSessionResource = requestedRelativePath.startsWith(allowedSessionPrefix); + const mappedSessionIds = managedContext.managedResource?.token.id + ? await resolveChatShareTokenRoomSessionIds(managedContext.managedResource.token.id) + : []; + const allowedSessionIds = Array.from(new Set([tokenPayload.sessionId, ...mappedSessionIds].filter(Boolean))); + const isAllowedSessionResource = allowedSessionIds.some((sessionId) => + requestedRelativePath.startsWith(path.posix.join('.codex_chat', sessionId, 'resource') + '/'), + ); const isAllowedManagedResource = requestedRelativePath.startsWith(RESOURCE_MANAGER_ROOT_PREFIX); if (!requestedRelativePath || (!isAllowedSessionResource && !isAllowedManagedResource)) { @@ -1556,6 +1711,13 @@ export async function registerChatRoutes(app: FastifyInstance) { }); await assignSharedResourceTokenToRequests(sessionId, [requestId], managedResourceTokenId); + await upsertChatShareTokenRoomMap({ + tokenId: managedResourceTokenId, + sessionId, + rootRequestId: requestId, + isDefault: true, + createdByClientId: clientId || null, + }); return { ok: true, @@ -1582,11 +1744,144 @@ export async function registerChatRoutes(app: FastifyInstance) { }; }); + app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/rooms`, async (request, reply) => { + const params = z.object({ + token: z.string().trim().min(1).max(16000), + }).parse(request.params ?? {}); + const payload = z.object({ + chatTypeId: z.string().trim().min(1).max(120), + chatTypeLabel: z.string().trim().min(1).max(200), + title: z.string().trim().min(1).max(200), + requestBadgeLabel: z.string().trim().max(120).optional().nullable(), + seedMessage: z.string().trim().min(1).max(20000), + }).parse(request.body ?? {}); + const managedContext = await resolveManagedChatShareContext(params.token); + const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); + + if (!tokenPayload) { + return reply.code(404).send({ + message: '공유 링크가 유효하지 않습니다.', + }); + } + + const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); + + if (unavailableMessage) { + return reply.code(403).send({ + message: unavailableMessage, + }); + } + + if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) { + return; + } + + if (!managedContext.managedResource || managedContext.managedResource.token.resourceType !== 'chat-share') { + return reply.code(404).send({ + message: '공유 채팅방 정보를 찾을 수 없습니다.', + }); + } + + if (!managedContext.managedResource.token.permissions.includes('manage')) { + return reply.code(403).send({ + message: '이 공유 링크에는 새 채팅방을 추가할 권한이 없습니다.', + }); + } + + const currentToken = managedContext.managedResource.token; + const clientId = getRequestClientId(request); + const sessionId = createManagedChatShareSessionId(); + const requestId = createManagedChatShareRequestId(); + const { userMessageId } = createManagedChatShareMessageIds(); + const createdAt = new Date().toISOString(); + + await createChatConversation({ + sessionId, + clientId: clientId || null, + title: payload.title, + requestBadgeLabel: payload.requestBadgeLabel ?? null, + chatTypeId: payload.chatTypeId, + lastChatTypeId: payload.chatTypeId, + contextLabel: payload.chatTypeLabel, + contextDescription: null, + notifyOffline: true, + }); + + await appendChatConversationMessage( + { + sessionId, + clientId: clientId || null, + title: payload.title, + requestBadgeLabel: payload.requestBadgeLabel ?? null, + chatTypeId: payload.chatTypeId, + lastChatTypeId: payload.chatTypeId, + contextLabel: payload.chatTypeLabel, + contextDescription: null, + notifyOffline: true, + }, + { + sessionId, + messageId: userMessageId, + author: 'user', + text: payload.seedMessage, + timestamp: createdAt, + clientRequestId: requestId, + parts: [], + }, + ); + + await upsertChatConversationRequest(sessionId, { + requestId, + requesterClientId: clientId || null, + chatTypeId: payload.chatTypeId, + chatTypeLabel: payload.chatTypeLabel, + requestOrigin: 'composer', + sharedResourceTokenId: currentToken.id, + status: 'completed', + statusMessage: '공유 채팅방 시작 요청을 준비했습니다.', + userMessageId, + userText: payload.seedMessage, + }); + + await assignSharedResourceTokenToRequests(sessionId, [requestId], currentToken.id); + await upsertChatShareTokenRoomMap({ + tokenId: currentToken.id, + sessionId, + rootRequestId: requestId, + isDefault: false, + createdByClientId: clientId || null, + }); + + const room = await getChatShareTokenRoomMap(currentToken.id, sessionId); + + return { + ok: true, + room: room + ? mapResolvedShareRoomItem(room) + : { + sessionId, + requestId, + isDefault: false, + sortOrder: 0, + title: payload.title, + requestBadgeLabel: payload.requestBadgeLabel ?? null, + chatTypeId: payload.chatTypeId, + lastChatTypeId: payload.chatTypeId, + contextLabel: payload.chatTypeLabel, + contextDescription: null, + notifyOffline: true, + createdAt, + updatedAt: createdAt, + }, + }; + }); + app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/room-settings`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const payload = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), accessPin: z.string().regex(/^\d{4}$/u).optional().nullable(), accessPinPromptTtlMinutes: z.number().int().min(0).max(7 * 24 * 60).optional().nullable(), chatTypeId: z.string().trim().min(1).max(120).optional().nullable(), @@ -1620,6 +1915,18 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + const currentToken = managedContext.managedResource.token; if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) { @@ -1639,6 +1946,8 @@ export async function registerChatRoutes(app: FastifyInstance) { resourceType: currentToken.resourceType, shareToken: currentToken.shareToken, sharePath: currentToken.sharePath, + resourceAllowedAppIds: currentToken.allowedAppIds, + resourceAllowedAppIdsOverrideEnabled: currentToken.resourceAllowedAppIdsOverrideEnabled, permissions: currentToken.permissions, enabled: currentToken.enabled, expiresAt: currentToken.expiresAt, @@ -1654,10 +1963,10 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - let updatedConversation = await getChatConversation(tokenPayload.sessionId, getRequestClientId(request)); + let updatedConversation = await getChatConversation(resolvedRoomContext.activeRoom.sessionId, getRequestClientId(request)); if (payload.chatTypeId || payload.title || payload.notifyOffline != null) { - updatedConversation = await updateChatConversationContext(tokenPayload.sessionId, { + updatedConversation = await updateChatConversationContext(resolvedRoomContext.activeRoom.sessionId, { clientId: getRequestClientId(request), chatTypeId: payload.chatTypeId?.trim() || undefined, lastChatTypeId: payload.chatTypeId?.trim() || undefined, @@ -1687,6 +1996,162 @@ export async function registerChatRoutes(app: FastifyInstance) { }; }); + app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token/runtime`, async (request, reply) => { + const params = z.object({ + token: z.string().trim().min(1).max(16000), + }).parse(request.params ?? {}); + const query = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), + }).parse(request.query ?? {}); + const managedContext = await resolveManagedChatShareContext(params.token); + const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); + + if (!tokenPayload) { + return reply.code(404).send({ + message: '공유 링크가 유효하지 않습니다.', + }); + } + + const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); + + if (unavailableMessage) { + return reply.code(403).send({ + message: unavailableMessage, + }); + } + + if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) { + return; + } + + if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) { + return reply.code(403).send({ + message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.', + }); + } + + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: query.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + return { + ok: true, + item: buildManagedShareRoomRuntimeSnapshot(chatRuntimeService.getSnapshot(), resolvedRoomContext.activeRoom.sessionId), + }; + }); + + app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/runtime-requests/:requestId/cancel`, async (request, reply) => { + const params = z.object({ + token: z.string().trim().min(1).max(16000), + requestId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + const payload = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), + }).parse(request.body ?? {}); + const managedContext = await resolveManagedChatShareContext(params.token); + const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); + + if (!tokenPayload) { + return reply.code(404).send({ + message: '공유 링크가 유효하지 않습니다.', + }); + } + + const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); + + if (unavailableMessage) { + return reply.code(403).send({ + message: unavailableMessage, + }); + } + + if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) { + return; + } + + if (!managedContext.managedResource?.token.permissions?.includes('manage')) { + return reply.code(403).send({ + message: '이 공유 링크에는 처리중 세션 관리 권한이 없습니다.', + }); + } + + if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) { + return reply.code(403).send({ + message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.', + }); + } + + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + const controller = getChatRuntimeController(); + + if (!controller) { + return reply.code(503).send({ + message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.', + }); + } + + const detail = controller.getJobDetail(params.requestId); + + if (!detail.item || detail.item.sessionId !== resolvedRoomContext.activeRoom.sessionId) { + return reply.code(404).send({ + message: '이 채팅방에서 취소할 처리중 세션을 찾지 못했습니다.', + }); + } + + if (detail.availableActions.remove) { + const removed = await controller.removeQueuedJob(params.requestId); + + if (!removed) { + return reply.code(409).send({ + message: '대기 요청 취소 처리에 실패했습니다. 상태를 새로고침한 뒤 다시 시도해 주세요.', + }); + } + + return { + ok: true, + action: 'removed', + }; + } + + if (detail.availableActions.cancel) { + const cancelled = await controller.cancelJob(params.requestId); + + if (!cancelled) { + return reply.code(409).send({ + message: '실행 중 요청 취소 처리에 실패했습니다. 상태를 새로고침한 뒤 다시 시도해 주세요.', + }); + } + + return { + ok: true, + action: 'cancelled', + }; + } + + return reply.code(409).send({ + message: '이미 종료된 요청이라 지금은 취소할 수 없습니다.', + }); + }); + app.post(`${CHAT_SHARE_ROUTE_PREFIX}`, async (request, reply) => { const parsedPayload = z.object({ kind: z.enum(['request-bundle', 'inquiry-message', 'prompt']), @@ -1853,6 +2318,9 @@ export async function registerChatRoutes(app: FastifyInstance) { const params = z.object({ token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); + const query = z.object({ + sessionId: z.string().trim().min(1).max(120).optional(), + }).parse(request.query ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); @@ -1862,14 +2330,6 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const shareSnapshot = await buildChatShareSnapshot(tokenPayload); - - if (!shareSnapshot) { - return reply.code(404).send({ - message: '공유 대상을 찾을 수 없습니다.', - }); - } - const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); if (unavailableMessage) { @@ -1882,6 +2342,31 @@ export async function registerChatRoutes(app: FastifyInstance) { return; } + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: query.sessionId ?? null, + }); + + const activeRoom = resolvedRoomContext.activeRoom; + + if (!activeRoom) { + return reply.code(404).send({ + message: '공유 채팅방 목록을 찾을 수 없습니다.', + }); + } + + const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { + sessionId: activeRoom.sessionId, + requestId: activeRoom.requestId, + }); + + if (!shareSnapshot) { + return reply.code(404).send({ + message: '공유 대상을 찾을 수 없습니다.', + }); + } + const accessPinStatus = await validateSharedResourceAccessPinBySharePath(managedContext.sharePath, getRequestChatSharePin(request), { clientId: getRequestClientId(request), }); @@ -1904,8 +2389,8 @@ export async function registerChatRoutes(app: FastifyInstance) { ok: true, share: { kind: tokenPayload.kind, - sessionId: tokenPayload.sessionId, - requestId: tokenPayload.requestId, + sessionId: activeRoom.sessionId, + requestId: activeRoom.requestId, sharePath: resolveChatSharePath(params.token), createdAt: managedContext.managedResource?.token.createdAt ?? null, expiresAt: effectiveExpiresAt, @@ -1928,8 +2413,8 @@ export async function registerChatRoutes(app: FastifyInstance) { blockedReason, }, conversation: { - sessionId: shareSnapshot.conversation?.sessionId ?? tokenPayload.sessionId, - title: shareSnapshot.conversation?.title ?? '공유 채팅', + sessionId: shareSnapshot.conversation?.sessionId ?? activeRoom.sessionId, + title: shareSnapshot.conversation?.title ?? activeRoom.title ?? '공유 채팅', requestBadgeLabel: shareSnapshot.conversation?.requestBadgeLabel ?? null, chatTypeId: shareSnapshot.conversation?.chatTypeId ?? null, lastChatTypeId: shareSnapshot.conversation?.lastChatTypeId ?? null, @@ -1943,6 +2428,8 @@ export async function registerChatRoutes(app: FastifyInstance) { messages: shareSnapshot.messages, activityLogs: shareSnapshot.activityLogs, roomRequestCounts: shareSnapshot.roomRequestCounts, + rooms: resolvedRoomContext.rooms, + activeSessionId: activeRoom.sessionId, promptTarget: shareSnapshot.promptTarget, refreshedAt: new Date().toISOString(), }; @@ -1952,6 +2439,9 @@ export async function registerChatRoutes(app: FastifyInstance) { const params = z.object({ token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); + const payload = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), + }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); @@ -1967,7 +2457,22 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const shareSnapshot = await buildChatShareSnapshot(tokenPayload); + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { + sessionId: resolvedRoomContext.activeRoom.sessionId, + requestId: resolvedRoomContext.activeRoom.requestId, + }); if (!shareSnapshot) { return reply.code(404).send({ @@ -1995,9 +2500,9 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - getActiveChatService()?.resetSessionData(tokenPayload.sessionId); - chatRuntimeService.clearSession(tokenPayload.sessionId); - const item = await clearChatConversationData(tokenPayload.sessionId, null); + getActiveChatService()?.resetSessionData(resolvedRoomContext.activeRoom.sessionId); + chatRuntimeService.clearSession(resolvedRoomContext.activeRoom.sessionId); + const item = await clearChatConversationData(resolvedRoomContext.activeRoom.sessionId, null); if (!item) { return reply.code(404).send({ @@ -2009,7 +2514,7 @@ export async function registerChatRoutes(app: FastifyInstance) { await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { actorLabel: 'share-viewer', summary: '공유 채팅에서 채팅방 기록을 초기화했습니다.', - detail: tokenPayload.sessionId, + detail: resolvedRoomContext.activeRoom.sessionId, }); } @@ -2027,6 +2532,7 @@ export async function registerChatRoutes(app: FastifyInstance) { text: z.string().trim().min(1).max(20000), mode: z.enum(['queue', 'direct']).optional(), parentRequestId: z.string().trim().min(1).max(120).optional().nullable(), + sessionId: z.string().trim().min(1).max(120).optional().nullable(), }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); @@ -2043,7 +2549,22 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const shareSnapshot = await buildChatShareSnapshot(tokenPayload); + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { + sessionId: resolvedRoomContext.activeRoom.sessionId, + requestId: resolvedRoomContext.activeRoom.requestId, + }); if (!shareSnapshot) { return reply.code(404).send({ @@ -2100,13 +2621,17 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, payload.text, { + const queuedRequestId = await getActiveChatService()?.submitExternalMessage( + resolvedRoomContext.activeRoom.sessionId, + payload.text, + { mode: payload.mode === 'direct' ? 'direct' : 'queue', requestOrigin: 'composer', sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, parentRequestId: resolvedParentRequestId, clientId: shareSnapshot.targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, - }); + }, + ); if (!queuedRequestId) { return reply.code(503).send({ @@ -2133,6 +2658,7 @@ export async function registerChatRoutes(app: FastifyInstance) { token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const payload = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), fileName: z.string().trim().max(255).optional(), mimeType: z.string().trim().max(200).optional(), contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2), @@ -2146,7 +2672,22 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const shareSnapshot = await buildChatShareSnapshot(tokenPayload); + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { + sessionId: resolvedRoomContext.activeRoom.sessionId, + requestId: resolvedRoomContext.activeRoom.requestId, + }); if (!shareSnapshot) { return reply.code(404).send({ @@ -2186,7 +2727,7 @@ export async function registerChatRoutes(app: FastifyInstance) { } const saved = await saveChatAttachmentFile({ - sessionId: tokenPayload.sessionId, + sessionId: resolvedRoomContext.activeRoom.sessionId, fileName: payload.fileName, mimeType: payload.mimeType, contentBase64: payload.contentBase64, @@ -2217,6 +2758,7 @@ export async function registerChatRoutes(app: FastifyInstance) { token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const payload = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), parentRequestId: z.string().trim().min(1).max(120), promptIndex: z.number().int().min(0).max(99), promptTitle: z.string().trim().min(1).max(500), @@ -2248,7 +2790,22 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const shareSnapshot = await buildChatShareSnapshot(tokenPayload); + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { + sessionId: resolvedRoomContext.activeRoom.sessionId, + requestId: resolvedRoomContext.activeRoom.requestId, + }); if (!shareSnapshot) { return reply.code(404).send({ @@ -2313,19 +2870,23 @@ export async function registerChatRoutes(app: FastifyInstance) { } const existingPromptRequest = await findExistingActivePromptFollowupRequest( - tokenPayload.sessionId, + resolvedRoomContext.activeRoom.sessionId, normalizedParentRequestId, payload.followupText, ); - const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, payload.followupText, { - mode: resolvePromptFollowupMode(payload.mode), - requestOrigin: 'prompt', - sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, - parentRequestId: normalizedParentRequestId, - promptContextRef: payload.contextRef ?? null, - clientId: shareSnapshot.targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, - }); + const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage( + resolvedRoomContext.activeRoom.sessionId, + payload.followupText, + { + mode: resolvePromptFollowupMode(payload.mode), + requestOrigin: 'prompt', + sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, + parentRequestId: normalizedParentRequestId, + promptContextRef: payload.contextRef ?? null, + clientId: shareSnapshot.targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, + }, + ); if (!queuedRequestId) { return reply.code(503).send({ @@ -2333,7 +2894,11 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const persisted = await persistChatConversationPromptSelection(tokenPayload.sessionId, normalizedParentRequestId, payload); + const persisted = await persistChatConversationPromptSelection( + resolvedRoomContext.activeRoom.sessionId, + normalizedParentRequestId, + payload, + ); if (!persisted) { return reply.code(404).send({ @@ -2341,8 +2906,8 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, persisted.request); - getActiveChatService()?.broadcastMessageUpdate(tokenPayload.sessionId, persisted.message); + getActiveChatService()?.broadcastRequestUpdate(resolvedRoomContext.activeRoom.sessionId, persisted.request); + getActiveChatService()?.broadcastMessageUpdate(resolvedRoomContext.activeRoom.sessionId, persisted.message); if (managedContext.managedResource) { await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { @@ -2365,6 +2930,7 @@ export async function registerChatRoutes(app: FastifyInstance) { token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const payload = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), parentRequestId: z.string().trim().min(1).max(120), type: z.enum(['prompt', 'verification']), }).parse(request.body ?? {}); @@ -2377,7 +2943,22 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const shareSnapshot = await buildChatShareSnapshot(tokenPayload); + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { + sessionId: resolvedRoomContext.activeRoom.sessionId, + requestId: resolvedRoomContext.activeRoom.requestId, + }); if (!shareSnapshot) { return reply.code(404).send({ @@ -2425,7 +3006,7 @@ export async function registerChatRoutes(app: FastifyInstance) { try { item = await markChatConversationRequestManualCompletion( - tokenPayload.sessionId, + resolvedRoomContext.activeRoom.sessionId, normalizedParentRequestId, payload.type, ); @@ -2445,7 +3026,7 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, item); + getActiveChatService()?.broadcastRequestUpdate(resolvedRoomContext.activeRoom.sessionId, item); return { ok: true, @@ -2458,6 +3039,7 @@ export async function registerChatRoutes(app: FastifyInstance) { token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const payload = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), parentRequestId: z.string().trim().min(1).max(120), }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); @@ -2469,7 +3051,22 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const shareSnapshot = await buildChatShareSnapshot(tokenPayload); + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { + sessionId: resolvedRoomContext.activeRoom.sessionId, + requestId: resolvedRoomContext.activeRoom.requestId, + }); if (!shareSnapshot) { return reply.code(404).send({ @@ -2527,7 +3124,7 @@ export async function registerChatRoutes(app: FastifyInstance) { } const result = await cancelUnansweredChatConversationRequest( - tokenPayload.sessionId, + resolvedRoomContext.activeRoom.sessionId, normalizedParentRequestId, '사용자 요청으로 중단된 요청을 취소 처리했습니다.', ); @@ -2556,7 +3153,7 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, result.item); + getActiveChatService()?.broadcastRequestUpdate(resolvedRoomContext.activeRoom.sessionId, result.item); return { ok: true, @@ -2569,6 +3166,7 @@ export async function registerChatRoutes(app: FastifyInstance) { token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const payload = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), parentRequestId: z.string().trim().min(1).max(120), }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); @@ -2580,7 +3178,22 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const shareSnapshot = await buildChatShareSnapshot(tokenPayload); + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { + sessionId: resolvedRoomContext.activeRoom.sessionId, + requestId: resolvedRoomContext.activeRoom.requestId, + }); if (!shareSnapshot) { return reply.code(404).send({ @@ -2656,14 +3269,18 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, normalizedUserText, { - requestId: normalizedParentRequestId, - mode: 'direct', - requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer', - sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, - parentRequestId: targetRequest.requestOrigin === 'prompt' ? targetRequest.parentRequestId?.trim() || null : null, - clientId: targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, - }); + const queuedRequestId = await getActiveChatService()?.submitExternalMessage( + resolvedRoomContext.activeRoom.sessionId, + normalizedUserText, + { + requestId: normalizedParentRequestId, + mode: 'direct', + requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer', + sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, + parentRequestId: targetRequest.requestOrigin === 'prompt' ? targetRequest.parentRequestId?.trim() || null : null, + clientId: targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, + }, + ); if (!queuedRequestId) { return reply.code(503).send({ diff --git a/etc/servers/work-server/src/services/chat-share-room-map-service.ts b/etc/servers/work-server/src/services/chat-share-room-map-service.ts new file mode 100644 index 0000000..43dc9aa --- /dev/null +++ b/etc/servers/work-server/src/services/chat-share-room-map-service.ts @@ -0,0 +1,312 @@ +import { db } from '../db/client.js'; +import { + CHAT_CONVERSATION_TABLE, + ensureChatConversationTables, +} from './chat-room-service.js'; + +const CHAT_SHARE_TOKEN_ROOM_MAP_TABLE = 'chat_share_token_room_maps'; + +export type ChatShareTokenRoomMapItem = { + tokenId: string; + sessionId: string; + rootRequestId: string; + isDefault: boolean; + sortOrder: number; + createdByClientId: string | null; + title: string; + requestBadgeLabel: string | null; + chatTypeId: string | null; + lastChatTypeId: string | null; + contextLabel: string | null; + contextDescription: string | null; + notifyOffline: boolean; + createdAt: string | null; + updatedAt: string | null; + conversationUpdatedAt: string | null; +}; + +function normalizeOptionalText(value: unknown) { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + return normalized || null; +} + +function normalizeRequiredText(value: unknown) { + if (typeof value !== 'string') { + return ''; + } + + return value.trim(); +} + +function normalizeBoolean(value: unknown) { + return value === true; +} + +function normalizeInteger(value: unknown, fallback = 0) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Math.trunc(parsed); +} + +function normalizeDateTime(value: unknown) { + if (value == null) { + return null; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + + if (typeof value === 'string') { + const normalized = value.trim(); + return normalized || null; + } + + return null; +} + +function mapChatShareTokenRoomRow(row: Record): ChatShareTokenRoomMapItem { + return { + tokenId: normalizeRequiredText(row.shared_resource_token_id), + sessionId: normalizeRequiredText(row.session_id), + rootRequestId: normalizeRequiredText(row.root_request_id), + isDefault: normalizeBoolean(row.is_default), + sortOrder: normalizeInteger(row.sort_order), + createdByClientId: normalizeOptionalText(row.created_by_client_id), + title: normalizeRequiredText(row.title) || '공유 채팅방', + requestBadgeLabel: normalizeOptionalText(row.request_badge_label), + chatTypeId: normalizeOptionalText(row.chat_type_id), + lastChatTypeId: normalizeOptionalText(row.last_chat_type_id), + contextLabel: normalizeOptionalText(row.context_label), + contextDescription: normalizeOptionalText(row.context_description), + notifyOffline: normalizeBoolean(row.notify_offline), + createdAt: normalizeDateTime(row.created_at), + updatedAt: normalizeDateTime(row.updated_at), + conversationUpdatedAt: normalizeDateTime(row.conversation_updated_at), + }; +} + +export async function ensureChatShareTokenRoomMapTable() { + await ensureChatConversationTables(); + + const hasTable = await db.schema.hasTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE); + + if (!hasTable) { + await db.schema.createTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, (table) => { + table.increments('id').primary(); + table.string('shared_resource_token_id', 120).notNullable().index(); + table.string('session_id', 120).notNullable().index(); + table.string('root_request_id', 120).notNullable(); + table.boolean('is_default').notNullable().defaultTo(false); + table.integer('sort_order').notNullable().defaultTo(0); + table.string('created_by_client_id', 120).nullable(); + table.timestamp('archived_at', { useTz: true }).nullable().index(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.unique(['shared_resource_token_id', 'session_id']); + }); + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['shared_resource_token_id', (table) => table.string('shared_resource_token_id', 120).notNullable().index()], + ['session_id', (table) => table.string('session_id', 120).notNullable().index()], + ['root_request_id', (table) => table.string('root_request_id', 120).notNullable().defaultTo('')], + ['is_default', (table) => table.boolean('is_default').notNullable().defaultTo(false)], + ['sort_order', (table) => table.integer('sort_order').notNullable().defaultTo(0)], + ['created_by_client_id', (table) => table.string('created_by_client_id', 120).nullable()], + ['archived_at', (table) => table.timestamp('archived_at', { useTz: true }).nullable().index()], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function listChatShareTokenRoomMaps(tokenId: string) { + const normalizedTokenId = tokenId.trim(); + + if (!normalizedTokenId) { + return [] as ChatShareTokenRoomMapItem[]; + } + + await ensureChatShareTokenRoomMapTable(); + + const rows = await db(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`) + .leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id') + .select( + 'room_map.shared_resource_token_id', + 'room_map.session_id', + 'room_map.root_request_id', + 'room_map.is_default', + 'room_map.sort_order', + 'room_map.created_by_client_id', + 'room_map.created_at', + 'room_map.updated_at', + 'conversation.title', + 'conversation.request_badge_label', + 'conversation.chat_type_id', + 'conversation.last_chat_type_id', + 'conversation.context_label', + 'conversation.context_description', + 'conversation.notify_offline', + 'conversation.updated_at as conversation_updated_at', + ) + .where({ 'room_map.shared_resource_token_id': normalizedTokenId }) + .whereNull('room_map.archived_at') + .orderBy('room_map.is_default', 'desc') + .orderBy('room_map.sort_order', 'asc') + .orderBy('room_map.created_at', 'asc'); + + return rows.map((row) => mapChatShareTokenRoomRow(row)); +} + +export async function getChatShareTokenRoomMap(tokenId: string, sessionId: string) { + const normalizedTokenId = tokenId.trim(); + const normalizedSessionId = sessionId.trim(); + + if (!normalizedTokenId || !normalizedSessionId) { + return null; + } + + const rooms = await listChatShareTokenRoomMaps(normalizedTokenId); + return rooms.find((item) => item.sessionId === normalizedSessionId) ?? null; +} + +export async function upsertChatShareTokenRoomMap(args: { + tokenId: string; + sessionId: string; + rootRequestId: string; + isDefault?: boolean; + sortOrder?: number | null; + createdByClientId?: string | null; +}) { + const normalizedTokenId = args.tokenId.trim(); + const normalizedSessionId = args.sessionId.trim(); + const normalizedRootRequestId = args.rootRequestId.trim(); + + if (!normalizedTokenId || !normalizedSessionId || !normalizedRootRequestId) { + return null; + } + + await ensureChatShareTokenRoomMapTable(); + + await db.transaction(async (trx) => { + const current = await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) + .where({ + shared_resource_token_id: normalizedTokenId, + session_id: normalizedSessionId, + }) + .whereNull('archived_at') + .first(); + + const maxSortOrderRow = await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) + .where({ shared_resource_token_id: normalizedTokenId }) + .whereNull('archived_at') + .max<{ max_sort_order?: number | string | null }>('sort_order as max_sort_order') + .first(); + const nextSortOrder = args.sortOrder != null + ? Math.max(0, Math.trunc(Number(args.sortOrder) || 0)) + : Math.max(0, normalizeInteger(maxSortOrderRow?.max_sort_order) + (current ? 0 : 1)); + + if (args.isDefault === true) { + await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) + .where({ shared_resource_token_id: normalizedTokenId }) + .whereNull('archived_at') + .update({ + is_default: false, + updated_at: db.fn.now(), + }); + } + + const payload = { + shared_resource_token_id: normalizedTokenId, + session_id: normalizedSessionId, + root_request_id: normalizedRootRequestId, + is_default: args.isDefault === true, + sort_order: nextSortOrder, + created_by_client_id: normalizeOptionalText(args.createdByClientId), + archived_at: null, + updated_at: db.fn.now(), + }; + + if (current) { + await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) + .where({ + shared_resource_token_id: normalizedTokenId, + session_id: normalizedSessionId, + }) + .update(payload); + return; + } + + await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE).insert({ + ...payload, + created_at: db.fn.now(), + }); + }); + + return getChatShareTokenRoomMap(normalizedTokenId, normalizedSessionId); +} + +export async function ensureDefaultChatShareTokenRoomMap(args: { + tokenId: string; + sessionId: string; + rootRequestId: string; + createdByClientId?: string | null; +}) { + const normalizedTokenId = args.tokenId.trim(); + const normalizedSessionId = args.sessionId.trim(); + const normalizedRootRequestId = args.rootRequestId.trim(); + + if (!normalizedTokenId || !normalizedSessionId || !normalizedRootRequestId) { + return []; + } + + const existing = await getChatShareTokenRoomMap(normalizedTokenId, normalizedSessionId); + + if (!existing) { + await upsertChatShareTokenRoomMap({ + tokenId: normalizedTokenId, + sessionId: normalizedSessionId, + rootRequestId: normalizedRootRequestId, + isDefault: true, + createdByClientId: args.createdByClientId ?? null, + }); + } + + const rooms = await listChatShareTokenRoomMaps(normalizedTokenId); + + if (rooms.some((item) => item.isDefault)) { + return rooms; + } + + await upsertChatShareTokenRoomMap({ + tokenId: normalizedTokenId, + sessionId: normalizedSessionId, + rootRequestId: normalizedRootRequestId, + isDefault: true, + createdByClientId: args.createdByClientId ?? null, + }); + + return listChatShareTokenRoomMaps(normalizedTokenId); +} + +export async function resolveChatShareTokenRoomSessionIds(tokenId: string) { + const rooms = await listChatShareTokenRoomMaps(tokenId); + return rooms.map((item) => item.sessionId).filter(Boolean); +} diff --git a/src/app/main/mainChatPanel/chatUtils.ts b/src/app/main/mainChatPanel/chatUtils.ts index 1c70039..43f4525 100644 --- a/src/app/main/mainChatPanel/chatUtils.ts +++ b/src/app/main/mainChatPanel/chatUtils.ts @@ -1901,6 +1901,54 @@ export async function fetchChatRuntimeSnapshot() { return response.item; } +export async function fetchChatShareRuntimeSnapshot( + token: string, + options?: { + sessionId?: string | null; + sharePin?: string | null; + }, +) { + const query = new URLSearchParams(); + const normalizedSessionId = options?.sessionId?.trim() || ''; + + if (normalizedSessionId) { + query.set('sessionId', normalizedSessionId); + } + + const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>( + `/shares/${encodeURIComponent(token)}/runtime${query.size > 0 ? `?${query.toString()}` : ''}`, + undefined, + { + allowUnauthenticated: true, + sharePin: options?.sharePin, + timeoutMs: 20000, + }, + ); + return response.item; +} + +export async function cancelChatShareRuntimeRequest( + token: string, + payload: { + requestId: string; + sessionId?: string | null; + }, +) { + const response = await requestChatApi<{ ok: boolean; action: 'cancelled' | 'removed' }>( + `/shares/${encodeURIComponent(token)}/runtime-requests/${encodeURIComponent(payload.requestId)}/cancel`, + { + method: 'POST', + body: JSON.stringify({ + sessionId: payload.sessionId?.trim() || undefined, + }), + }, + { + allowUnauthenticated: true, + }, + ); + return response.action; +} + export async function fetchChatSourceChanges(limit = 300) { const query = new URLSearchParams(); query.set('limit', String(Math.max(1, Math.min(500, Math.round(limit))))); @@ -2268,11 +2316,14 @@ export async function clearChatConversationRoom(sessionId: string) { }; } -export async function clearChatShareConversationRoom(token: string) { +export async function clearChatShareConversationRoom(token: string, sessionId?: string | null) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>( `/shares/${encodeURIComponent(token)}/clear`, { method: 'POST', + body: JSON.stringify({ + sessionId: sessionId?.trim() || undefined, + }), }, { allowUnauthenticated: true, @@ -2282,6 +2333,44 @@ export async function clearChatShareConversationRoom(token: string) { return response.item; } +export async function createChatShareRoom( + token: string, + payload: { + chatTypeId: string; + chatTypeLabel: string; + title: string; + requestBadgeLabel?: string | null; + seedMessage: string; + }, +) { + const response = await requestChatApi<{ ok: boolean; room: ChatShareRoomSummary }>( + `/shares/${encodeURIComponent(token)}/rooms`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + { + allowUnauthenticated: true, + }, + ); + + return { + sessionId: normalizeRequiredText(response.room.sessionId), + requestId: normalizeRequiredText(response.room.requestId), + isDefault: response.room.isDefault === true, + sortOrder: Number.isFinite(response.room.sortOrder) ? Number(response.room.sortOrder) : 0, + title: normalizeRequiredText(response.room.title) || '공유 채팅방', + requestBadgeLabel: normalizeOptionalText(response.room.requestBadgeLabel), + chatTypeId: normalizeOptionalText(response.room.chatTypeId), + lastChatTypeId: normalizeOptionalText(response.room.lastChatTypeId), + contextLabel: normalizeOptionalText(response.room.contextLabel), + contextDescription: normalizeOptionalText(response.room.contextDescription), + notifyOffline: response.room.notifyOffline === true, + createdAt: normalizeOptionalText(response.room.createdAt), + updatedAt: normalizeOptionalText(response.room.updatedAt), + } satisfies ChatShareRoomSummary; +} + export async function deleteChatConversationRequest(sessionId: string, requestId: string) { const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string; requestId: string }>( `/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}`, @@ -2313,6 +2402,7 @@ export async function persistChatPromptSelection( sessionId: string, payload: { parentRequestId: string; + sessionId?: string | null; promptIndex: number; promptTitle: string; promptSignature: string; @@ -2391,6 +2481,22 @@ export async function submitChatPromptSelection( export type ChatShareKind = 'request-bundle' | 'inquiry-message' | 'prompt'; +export type ChatShareRoomSummary = { + sessionId: string; + requestId: string; + isDefault: boolean; + sortOrder: number; + title: string; + requestBadgeLabel?: string | null; + chatTypeId?: string | null; + lastChatTypeId?: string | null; + contextLabel?: string | null; + contextDescription?: string | null; + notifyOffline?: boolean; + createdAt?: string | null; + updatedAt?: string | null; +}; + export type ChatShareSnapshot = { share: { kind: ChatShareKind; @@ -2432,6 +2538,8 @@ export type ChatShareSnapshot = { requests: ChatConversationRequest[]; messages: ChatMessage[]; activityLogs: ChatConversationActivityLog[]; + rooms: ChatShareRoomSummary[]; + activeSessionId?: string | null; roomRequestCounts?: { processingCount: number; unansweredCount: number; @@ -2565,6 +2673,7 @@ export async function createManagedChatShareRoom(payload: ManagedChatShareRoomDr export async function saveChatShareRoomSettings( token: string, input: { + sessionId?: string | null; accessPin?: string | null; accessPinPromptTtlMinutes?: number | null; chatTypeId?: string | null; @@ -2592,6 +2701,7 @@ export async function saveChatShareRoomSettings( { method: 'POST', body: JSON.stringify({ + sessionId: input.sessionId, accessPin: input.accessPin, accessPinPromptTtlMinutes: input.accessPinPromptTtlMinutes, chatTypeId: input.chatTypeId, @@ -2626,7 +2736,7 @@ export async function saveChatShareRoomSettings( }; } -export async function fetchChatShareSnapshot(token: string, options?: { sharePin?: string | null }) { +export async function fetchChatShareSnapshot(token: string, options?: { sharePin?: string | null; sessionId?: string | null }) { const response = await requestChatApi<{ ok: boolean; share: ChatShareSnapshot['share']; @@ -2636,11 +2746,13 @@ export async function fetchChatShareSnapshot(token: string, options?: { sharePin requests: ChatConversationRequest[]; messages: ChatMessage[]; activityLogs: ChatConversationActivityLog[]; + rooms?: ChatShareRoomSummary[]; + activeSessionId?: string | null; roomRequestCounts?: ChatShareSnapshot['roomRequestCounts']; promptTarget?: ChatShareSnapshot['promptTarget']; refreshedAt: string; }>( - `/shares/${encodeURIComponent(token)}`, + `/shares/${encodeURIComponent(token)}${options?.sessionId?.trim() ? `?sessionId=${encodeURIComponent(options.sessionId.trim())}` : ''}`, undefined, { allowUnauthenticated: true, @@ -2707,6 +2819,24 @@ export async function fetchChatShareSnapshot(token: string, options?: { sharePin ? response.messages.map((message, index) => normalizeChatMessage(message, index)) : [], activityLogs: Array.isArray(response.activityLogs) ? response.activityLogs : [], + rooms: Array.isArray(response.rooms) + ? response.rooms.map((item) => ({ + sessionId: normalizeRequiredText(item.sessionId), + requestId: normalizeRequiredText(item.requestId), + isDefault: item.isDefault === true, + sortOrder: Number.isFinite(item.sortOrder) ? Number(item.sortOrder) : 0, + title: normalizeRequiredText(item.title) || '공유 채팅방', + requestBadgeLabel: normalizeOptionalText(item.requestBadgeLabel), + chatTypeId: normalizeOptionalText(item.chatTypeId), + lastChatTypeId: normalizeOptionalText(item.lastChatTypeId), + contextLabel: normalizeOptionalText(item.contextLabel), + contextDescription: normalizeOptionalText(item.contextDescription), + notifyOffline: item.notifyOffline === true, + createdAt: normalizeOptionalText(item.createdAt), + updatedAt: normalizeOptionalText(item.updatedAt), + })) + : [], + activeSessionId: normalizeOptionalText(response.activeSessionId), roomRequestCounts: response.roomRequestCounts ? { processingCount: Number.isFinite(response.roomRequestCounts.processingCount) ? response.roomRequestCounts.processingCount : 0, @@ -2722,6 +2852,7 @@ export async function submitChatShareMessage( token: string, text: string, options?: { + sessionId?: string | null; mode?: 'queue' | 'direct'; parentRequestId?: string | null; }, @@ -2732,6 +2863,7 @@ export async function submitChatShareMessage( method: 'POST', body: JSON.stringify({ text, + sessionId: options?.sessionId?.trim() || undefined, mode: options?.mode === 'direct' ? 'direct' : 'queue', parentRequestId: options?.parentRequestId?.trim() || undefined, }), @@ -2790,6 +2922,7 @@ export async function completeChatShareManualBadge( token: string, payload: { parentRequestId: string; + sessionId?: string | null; type: 'prompt' | 'verification'; }, ) { @@ -2811,6 +2944,7 @@ export async function cancelChatShareRequest( token: string, payload: { parentRequestId: string; + sessionId?: string | null; }, ) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest }>( @@ -2831,6 +2965,7 @@ export async function retryChatShareRequest( token: string, payload: { parentRequestId: string; + sessionId?: string | null; }, ) { return requestChatApi<{ ok: boolean; queuedRequestId: string }>( diff --git a/src/app/main/pages/ChatSharePage.css b/src/app/main/pages/ChatSharePage.css index b89b55e..208c150 100644 --- a/src/app/main/pages/ChatSharePage.css +++ b/src/app/main/pages/ChatSharePage.css @@ -210,6 +210,71 @@ box-shadow: inset 0 0 0 1px rgba(219, 226, 236, 0.82); } +.chat-share-page__room-list-panel { + padding: 10px; + border-radius: 14px; + background: rgba(248, 250, 252, 0.94); + box-shadow: inset 0 0 0 1px rgba(219, 226, 236, 0.82); +} + +.chat-share-page__room-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + gap: 8px; +} + +.chat-share-page__room-card { + display: grid; + gap: 6px; + padding: 12px; + border: 0; + border-radius: 14px; + background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%); + box-shadow: + inset 0 0 0 1px rgba(191, 204, 220, 0.82), + 0 6px 18px rgba(148, 163, 184, 0.08); + text-align: left; + cursor: pointer; +} + +.chat-share-page__room-card--active { + background: linear-gradient(180deg, #dbeafe 0%, #d7ecff 100%); + box-shadow: + inset 0 0 0 1px rgba(59, 130, 246, 0.38), + 0 10px 24px rgba(59, 130, 246, 0.16); +} + +.chat-share-page__room-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.chat-share-page__room-card-title { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 700; + line-height: 1.35; +} + +.chat-share-page__room-card-meta { + color: #64748b; + font-size: 12px; + line-height: 1.4; +} + +.chat-share-page__create-room-form { + display: grid; + gap: 12px; +} + +.chat-share-page__create-room-field { + display: grid; + gap: 6px; +} + .chat-share-page__message-list { display: flex; flex: 1 1 auto; @@ -222,6 +287,26 @@ padding-right: 0; } +.chat-share-page__conversation-loading-block { + display: grid; + flex: 1 1 auto; + align-content: center; + justify-items: center; + gap: 10px; + min-height: 240px; + padding: 24px 12px; + text-align: center; +} + +.chat-share-page__composer-loading-block { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + min-height: 132px; + padding: 16px 0; +} + .chat-share-page__search-modal { top: 16px; padding-bottom: 16px; @@ -947,24 +1032,50 @@ display: grid; gap: 14px; min-height: calc(100vh - 48px); + min-width: 0; padding: 14px 16px 18px; } +.chat-share-page__room-settings-tabs { + min-width: 0; +} + .chat-share-page__room-settings-tabs .ant-tabs-nav { margin-bottom: 12px; + min-width: 0; +} + +.chat-share-page__room-settings-tabs .ant-tabs-nav-wrap { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.chat-share-page__room-settings-tabs .ant-tabs-nav-list { + min-width: max-content; } .chat-share-page__room-settings-tabs .ant-tabs-tab { + flex: 0 0 auto; padding: 8px 0; } .chat-share-page__room-settings-tabs .ant-tabs-content-holder { + min-width: 0; min-height: calc(100vh - 140px); + overflow-x: hidden; +} + +.chat-share-page__room-settings-tabs .ant-tabs-content, +.chat-share-page__room-settings-tabs .ant-tabs-tabpane { + min-width: 0; } .chat-share-page__room-settings-panel { display: grid; gap: 14px; + min-width: 0; + overflow-x: hidden; } .chat-share-page__room-settings-panel-head { @@ -999,6 +1110,29 @@ gap: 8px; } +.chat-share-page__room-settings-runtime-list { + display: grid; + gap: 10px; +} + +.chat-share-page__room-settings-runtime-card { + display: grid; + gap: 8px; + padding: 14px 16px; + border: 1px solid rgba(191, 219, 254, 0.9); + border-radius: 18px; + background: rgba(248, 250, 252, 0.96); + box-shadow: 0 10px 24px rgba(148, 163, 184, 0.08); +} + +.chat-share-page__room-settings-runtime-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} + .chat-share-page__room-settings-checkbox-group { display: grid; gap: 10px; @@ -1586,19 +1720,49 @@ max-width: min(82%, 920px); border: 1px solid #dbe2ec; border-radius: 14px; - padding: 10px 12px 9px; + padding: 12px 12px 9px; margin-top: 4px; } -.chat-share-page__message-tone-label { - position: absolute; - top: -9px; +.chat-share-page__message-tone-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.chat-share-page__message-tone-meta { display: inline-flex; align-items: center; + gap: 8px; + min-width: 0; +} + +.chat-share-page__message-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + margin-left: auto; +} + +.chat-share-page__message-tone-label { + display: inline-flex; + align-items: center; + min-height: 20px; padding: 0 6px; color: #64748b; font-size: 11px; line-height: 1.4; + border-radius: 999px; +} + +.chat-share-page__message-tone-time { + color: #94a3b8; + font-size: 11px; + line-height: 1.4; + white-space: nowrap; } .chat-share-page__message-tone--question { @@ -1610,7 +1774,7 @@ } .chat-share-page__message-tone--question .chat-share-page__message-tone-label { - right: 12px; + margin-left: auto; background: #e7f1ff; } @@ -1622,10 +1786,24 @@ } .chat-share-page__message-tone--answer .chat-share-page__message-tone-label { - left: 12px; background: #e7f7ee; } +.chat-share-page__message-action-button.ant-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; + color: #475569; +} + +.chat-share-page__message-action-button.ant-btn.ant-btn-dangerous { + color: #dc2626; +} + .chat-share-page__response-block { display: grid; @@ -2034,21 +2212,6 @@ margin: 0; } -.chat-share-page__composer-send-mode { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-width: 0; - flex-wrap: wrap; -} - -.chat-share-page__composer-send-mode-text.ant-typography { - margin-bottom: 0; - font-size: 12px; - line-height: 1.45; -} - .chat-share-page__reply-reference { display: flex; align-items: center; @@ -2222,16 +2385,6 @@ padding: 0 6px 6px; } -.chat-share-page__request-block > .chat-share-page__message-time { - justify-self: end; - padding: 0 0 0 6px; -} - -.chat-share-page__response-block > .chat-share-page__message-time { - justify-self: start; - padding: 0 6px 0 0; -} - .chat-share-page__section-head--compact { margin-bottom: 8px; } diff --git a/src/app/main/pages/ChatSharePage.tsx b/src/app/main/pages/ChatSharePage.tsx index d856fd7..e9c383e 100644 --- a/src/app/main/pages/ChatSharePage.tsx +++ b/src/app/main/pages/ChatSharePage.tsx @@ -1,4 +1,4 @@ -import { AppstoreOutlined, CheckOutlined, CloseOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons'; +import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons'; import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; @@ -36,10 +36,13 @@ import { import { ChatPromptCard, buildPromptTargetSignature, type PromptDraftSelection, type PromptSubmitPayload } from '../mainChatPanel/ChatPromptCard'; import { ChatPreviewBody, type ChatPreviewTarget } from '../mainChatPanel/ChatPreviewBody'; import { + cancelChatShareRuntimeRequest, cancelChatShareRequest, ChatApiError, clearChatShareConversationRoom, completeChatShareManualBadge, + createChatShareRoom, + fetchChatShareRuntimeSnapshot, fetchChatShareSnapshot, getStoredChatShareAccessPin, resolveChatWebSocketUrl, @@ -49,6 +52,7 @@ import { submitChatShareMessage, submitChatSharePrompt, uploadChatShareComposerFile, + type ChatShareRoomSummary, type ChatShareSnapshot, } from '../mainChatPanel/chatUtils'; import { extractAttachmentPreviewUrls, extractChatMessageParts } from '../mainChatPanel/messageParts'; @@ -57,7 +61,16 @@ import { extractPreviewItems, type PreviewItem } from '../mainChatPanel/previewI import { buildChatPath, buildPlayAppPath } from '../routes'; import type { PreviewKind } from '../mainChatPanel/previewKind'; import { normalizeChatResourceUrl } from '../mainChatPanel/chatResourceUrl'; -import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatMessagePart, ChatServerEvent } from '../mainChatPanel/types'; +import type { + ChatComposerAttachment, + ChatConversationRequest, + ChatMessage, + ChatMessagePart, + ChatRuntimeJobItem, + ChatRuntimeSnapshot, + ChatRuntimeTerminalStatus, + ChatServerEvent, +} from '../mainChatPanel/types'; import { isPromptResolved } from '../mainChatPanel/promptState'; import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi'; import { copyTextToClipboard } from '../../../utils/clipboard'; @@ -639,6 +652,7 @@ const SHARE_APP_LAUNCH_USAGE_STORAGE_KEY = 'chat-share-page:app-launch-usage:v1' const CHAT_SHARE_INSTALL_NAME = '리소스 공유 채팅방'; const CHAT_SHARE_INSTALL_SHORT_NAME = '공유채팅'; const CHAT_SHARE_INSTALL_THEME_COLOR = '#165dff'; +const SHARE_CURRENT_CHAT_APP_ID = 'shared-chat-current'; type TokenUsagePeriodKey = (typeof TOKEN_USAGE_PERIODS)[number]['key']; @@ -915,6 +929,18 @@ function buildPlayAppEnvironmentUrl(appId: string, environment: ShareAppEnvironm return new URL(buildPlayAppPath(appId, 'embedded'), origin).toString(); } +function buildShareChatEnvironmentUrl( + sharePath: string | null | undefined, + shareToken: string | null | undefined, + environment: ShareAppEnvironment, +) { + const origin = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.origin ?? SHARE_APP_ENVIRONMENT_OPTIONS[0].origin; + const normalizedSharePath = sharePath?.trim() ?? ''; + const normalizedShareToken = shareToken?.trim() ?? ''; + const pathname = normalizedSharePath || (normalizedShareToken ? `/chat/share/${encodeURIComponent(normalizedShareToken)}` : '/chat/live'); + return new URL(pathname, origin).toString(); +} + function buildSharePlayAppInstallPath(appId: string, shareToken?: string | null) { const installPath = new URL(buildPlayAppPath(appId), 'https://preview.sm-home.cloud'); const normalizedShareToken = shareToken?.trim() ?? ''; @@ -970,6 +996,23 @@ function buildPlayAppEnvironmentTarget( }; } +function buildShareChatEnvironmentTarget( + sharePath: string | null | undefined, + shareToken: string | null | undefined, + environment: ShareAppEnvironment, +): ShareProgramTarget { + const environmentLabel = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.label ?? environment; + + return { + key: `launcher:${environment}:${SHARE_CURRENT_CHAT_APP_ID}`, + label: '채팅', + url: buildShareChatEnvironmentUrl(sharePath, shareToken, environment), + kind: 'document', + meta: `현재 공유토큰 열기 · ${environmentLabel}`, + appId: SHARE_CURRENT_CHAT_APP_ID, + }; +} + function shouldRenderSharePlayAppInline(target: ShareProgramTarget | null | undefined) { if (!target?.appId || !findReadyPlayAppEntryById(target.appId)) { return false; @@ -1261,6 +1304,22 @@ function resolveRequestUsageAt(request: ChatConversationRequest) { return 0; } +function resolveRequestMessageTimestamp(request: ChatConversationRequest) { + const candidates = [request.answeredAt, request.terminalAt, request.updatedAt, request.createdAt]; + + for (const candidate of candidates) { + const normalized = candidate?.trim(); + + if (!normalized) { + continue; + } + + return normalized; + } + + return ''; +} + function resolveRequestUsageTokens(request: ChatConversationRequest) { return Math.max( 0, @@ -2176,6 +2235,50 @@ function formatElapsedDuration(startedAt: string | null | undefined, nowMs: numb return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } +function formatShareRuntimeTimestamp(value: string | null | undefined) { + const normalized = value?.trim(); + + if (!normalized) { + return '-'; + } + + const parsed = new Date(normalized); + + if (Number.isNaN(parsed.getTime())) { + return '-'; + } + + return parsed.toLocaleString('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +} + +function resolveShareRuntimeTerminalTag(terminalStatus: ChatRuntimeTerminalStatus) { + switch (terminalStatus) { + case 'cancelled': + return { color: 'gold', label: '취소됨' } as const; + case 'removed': + return { color: 'default', label: '대기취소' } as const; + case 'failed': + return { color: 'red', label: '실패' } as const; + case 'completed': + default: + return { color: 'green', label: '완료' } as const; + } +} + +function resolveShareRuntimeStatusTag(item: ChatRuntimeJobItem) { + if (item.status === 'running') { + return { color: 'processing', label: '실행중' } as const; + } + + return { color: 'default', label: '대기중' } as const; +} + async function createSharePreviewFetchError(response: Response): Promise { const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; let responseMessage = ''; @@ -2261,6 +2364,33 @@ function ExpandableMessageText({ ); } +function ShareMessageTextBlock({ + tone, + label, + text, + timestamp, + actions, +}: { + tone: 'question' | 'answer'; + label: string; + text: string; + timestamp?: string | null; + actions?: ReactNode; +}) { + return ( +
+
+
+ {label} + {timestamp ? {timestamp} : null} +
+ {actions ?
{actions}
: null} +
+ +
+ ); +} + function ShareResponseBlock({ message, requestById, @@ -2285,6 +2415,7 @@ function ShareResponseBlock({ onUploadAttachment, onSetResponseAnchor, onSetPromptAnchor, + onCopyMessage, }: { message: ChatMessage; requestById: Map; @@ -2309,6 +2440,7 @@ function ShareResponseBlock({ onUploadAttachment?: ((file: File) => Promise) | null; onSetResponseAnchor?: ((messageId: number, element: HTMLDivElement | null) => void) | null; onSetPromptAnchor?: ((messageId: number, promptIndex: number, element: HTMLDivElement | null) => void) | null; + onCopyMessage?: ((text: string, label: string) => void) | null; }) { const { visibleText, promptParts: rawPromptParts, diffBlocks, previewItems: rawPreviewItems } = useMemo( () => extractShareMessageRenderPayload(message), @@ -2353,6 +2485,64 @@ function ShareResponseBlock({ Boolean(onReplyToResponse) && !isParentRequestInFlight; const isReplyTargetActive = canReplyToResponse && activeReplyRequestId?.trim() === parentRequestId; + const answerActions = ( + <> + {canCompletePrompt && parentRequestId && onCompletePrompt ? ( + - ) : null} {!canCompletePrompt && isPromptManualCompleted ? prompt 완료 처리됨 : null} - {canCompleteVerification && parentRequestId && onCompleteVerification ? ( - - ) : null} {!canCompleteVerification && !hasOpenPromptInResponse && !hasChildRequest && isVerificationCompleted ? 응답 확인 완료 : null} - {canReplyToResponse && parentRequestId && onReplyToResponse ? ( - - ) : null} - -
- 답변 -
+ {promptParts.length > 0 ? promptParts.map((target, promptIndex) => { const promptSignature = buildPromptTargetSignature(target); @@ -2741,6 +2890,11 @@ function ShareRequestCard({ onSetResponseAnchor, onSetPromptAnchor, onOpenPreviousQuestion, + onCopyMessage, + onCancelActiveRequest, + isActiveRequestCancellationSaving = false, + onResubmitRequestDirect, + isDirectResubmitSaving = false, }: { request: ChatConversationRequest; requestById: Map; @@ -2771,6 +2925,11 @@ function ShareRequestCard({ onSetResponseAnchor?: ((messageId: number, element: HTMLDivElement | null) => void) | null; onSetPromptAnchor?: ((messageId: number, promptIndex: number, element: HTMLDivElement | null) => void) | null; onOpenPreviousQuestion?: ((requestId: string) => void) | null; + onCopyMessage?: ((text: string, label: string) => void) | null; + onCancelActiveRequest?: ((requestId: string) => Promise) | null; + isActiveRequestCancellationSaving?: boolean; + onResubmitRequestDirect?: ((requestId: string) => Promise) | null; + isDirectResubmitSaving?: boolean; }) { const questionText = useMemo(() => buildShareVisibleText(request.userText), [request.userText]); const questionPreviewItems = useMemo( @@ -2807,7 +2966,132 @@ function ShareRequestCard({ && !request.hasResponse && request.status === 'failed' && (request.statusMessage?.trim() ?? '') === '중단된 오래된 요청'; + const canCancelActiveRequest = + Boolean(onCancelActiveRequest) + && !request.hasResponse + && isRequestInFlight(request.status); + const canResubmitDirectRequest = + Boolean(onResubmitRequestDirect) + && !request.hasResponse + && request.status === 'queued'; const retryCount = Math.max(0, Number(request.retryCount ?? 0) || 0); + const questionActions = ( + <> + {canCancelDisconnectedRequest ? ( + - ) : null} - {canRetryDisconnectedRequest ? ( - - ) : null} - + {isRetriedRequest(request) ? ( +
+ 재처리 {retryCount}회 +
+ ) : null} {shouldRenderQuestion ? ( <> -
- 질문 - -
+ {questionPreviewItems.length > 0 ? (
{questionPreviewItems.map((item) => ( @@ -2875,44 +3134,18 @@ function ShareRequestCard({ {shouldRenderAnswerSummary ? ( <> @@ -2973,6 +3207,14 @@ export function ChatSharePage() { const programmaticScrollTargetRef = useRef<'top' | 'bottom' | null>(null); const lastScrollTopRef = useRef(0); const [snapshot, setSnapshot] = useState(null); + const [requestedRoomSessionId, setRequestedRoomSessionId] = useState(() => { + if (typeof window === 'undefined') { + return ''; + } + + return new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || ''; + }); + const requestedRoomSessionIdRef = useRef(requestedRoomSessionId); const [isLoading, setIsLoading] = useState(true); const [, setIsRefreshing] = useState(false); const [isLiveConnected, setIsLiveConnected] = useState(false); @@ -2997,6 +3239,8 @@ export function ChatSharePage() { const [pendingVerificationCompletionRequestIds, setPendingVerificationCompletionRequestIds] = useState([]); const [pendingRequestCancellationIds, setPendingRequestCancellationIds] = useState([]); const [pendingRequestRetryIds, setPendingRequestRetryIds] = useState([]); + const [isShareRoomListVisible, setIsShareRoomListVisible] = useState(true); + const [isRoomSwitching, setIsRoomSwitching] = useState(false); const [replyReferenceRequestId, setReplyReferenceRequestId] = useState(''); const [previousQuestionModalRequestId, setPreviousQuestionModalRequestId] = useState(''); const [showScrollToTop, setShowScrollToTop] = useState(false); @@ -3005,8 +3249,10 @@ export function ChatSharePage() { const [isSearchOpen, setIsSearchOpen] = useState(false); const [isTokenUsageOpen, setIsTokenUsageOpen] = useState(false); const [isRoomSettingsOpen, setIsRoomSettingsOpen] = useState(false); + const [isCreateRoomOpen, setIsCreateRoomOpen] = useState(false); const [isSavingRoomSettings, setIsSavingRoomSettings] = useState(false); - const [roomSettingsTabKey, setRoomSettingsTabKey] = useState<'chat-type' | 'default-contexts' | 'room-context' | 'notifications' | 'security'>('chat-type'); + const [isCreatingRoom, setIsCreatingRoom] = useState(false); + const [roomSettingsTabKey, setRoomSettingsTabKey] = useState<'chat-type' | 'default-contexts' | 'room-context' | 'notifications' | 'security' | 'runtime'>('chat-type'); const [editingRoomTitle, setEditingRoomTitle] = useState(''); const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState(null); const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState([]); @@ -3017,6 +3263,12 @@ export function ChatSharePage() { const [editingRoomAccessPin, setEditingRoomAccessPin] = useState(''); const [editingRoomAccessPinPromptTtlMinutes, setEditingRoomAccessPinPromptTtlMinutes] = useState(null); const [editingRoomNotifyOffline, setEditingRoomNotifyOffline] = useState(false); + const [creatingRoomTitle, setCreatingRoomTitle] = useState(''); + const [creatingRoomChatTypeId, setCreatingRoomChatTypeId] = useState(null); + const [creatingRoomRequestBadgeLabel, setCreatingRoomRequestBadgeLabel] = useState(''); + const [creatingRoomSeedMessage, setCreatingRoomSeedMessage] = useState( + '이 방에서 이어갈 작업 내용을 남겨 주세요. 필요한 파일이나 참고 문맥이 있으면 함께 적어 주세요.', + ); const [roomNotificationClientStatus, setRoomNotificationClientStatus] = useState(() => buildShareNotificationClientStatus({ roomEnabled: false, @@ -3025,6 +3277,10 @@ export function ChatSharePage() { registrationReady: false, }), ); + const [shareRuntimeSnapshot, setShareRuntimeSnapshot] = useState(null); + const [isShareRuntimeLoading, setIsShareRuntimeLoading] = useState(false); + const [pendingShareRuntimeRequestIds, setPendingShareRuntimeRequestIds] = useState([]); + const [optimisticShareRooms, setOptimisticShareRooms] = useState([]); const [isRefreshingRoomNotificationStatus, setIsRefreshingRoomNotificationStatus] = useState(false); const [isSendingRoomNotificationTest, setIsSendingRoomNotificationTest] = useState(false); const [searchKeyword, setSearchKeyword] = useState(''); @@ -3054,8 +3310,10 @@ export function ChatSharePage() { const composerFocusScrollTimerIdsRef = useRef([]); const [isComposerViewportCompacted, setIsComposerViewportCompacted] = useState(false); const [appLaunchUsage, setAppLaunchUsage] = useState(() => readShareAppLaunchUsage()); + const roomSwitchSequenceRef = useRef(0); hasSnapshotRef.current = snapshot != null; requiresAccessPinRef.current = requiresAccessPin; + requestedRoomSessionIdRef.current = requestedRoomSessionId; const shareTokenSetting = snapshot?.share.tokenSetting ?? null; const shareAllowedAppIdSet = useMemo( @@ -3066,6 +3324,44 @@ export function ChatSharePage() { () => new Set((snapshot?.share.permissions ?? []).map((item) => item.trim().toLowerCase()).filter(Boolean)), [snapshot?.share.permissions], ); + const shareRooms = useMemo(() => { + const snapshotRooms = snapshot?.rooms ?? []; + + if (optimisticShareRooms.length === 0) { + return snapshotRooms; + } + + const nextRooms = [...snapshotRooms]; + const knownSessionIds = new Set(snapshotRooms.map((room) => room.sessionId)); + + optimisticShareRooms.forEach((room) => { + if (!knownSessionIds.has(room.sessionId)) { + nextRooms.push(room); + } + }); + + return nextRooms; + }, [optimisticShareRooms, snapshot?.rooms]); + const activeShareRoomSessionId = snapshot?.activeSessionId?.trim() || snapshot?.share.sessionId?.trim() || ''; + const selectedShareRoomSessionId = requestedRoomSessionId.trim() || activeShareRoomSessionId; + const activeShareRoom = useMemo( + () => shareRooms.find((item) => item.sessionId === selectedShareRoomSessionId) ?? null, + [selectedShareRoomSessionId, shareRooms], + ); + useEffect(() => { + if (optimisticShareRooms.length === 0 || !snapshot?.rooms?.length) { + return; + } + + const snapshotRoomIds = new Set(snapshot.rooms.map((room) => room.sessionId)); + setOptimisticShareRooms((current) => { + const next = current.filter((room) => !snapshotRoomIds.has(room.sessionId)); + return next.length === current.length ? current : next; + }); + }, [optimisticShareRooms.length, snapshot?.rooms]); + const shareRuntimeRunningItems = shareRuntimeSnapshot?.running ?? []; + const shareRuntimeQueuedItems = shareRuntimeSnapshot?.queued ?? []; + const shareRuntimeRecentItems = shareRuntimeSnapshot?.recent ?? []; const allowedPlayAppEntries = useMemo( () => getReadyPlayAppEntries().filter((entry) => shareAllowedAppIdSet.has(entry.id)), [shareAllowedAppIdSet], @@ -3100,6 +3396,10 @@ export function ChatSharePage() { () => SHARE_MANAGEMENT_APP_OPTIONS.filter((option) => shareAllowedAppIdSet.has(option.value)), [shareAllowedAppIdSet], ); + const currentShareChatTarget = useMemo( + () => buildShareChatEnvironmentTarget(snapshot?.share.sharePath ?? null, normalizedToken, selectedAppEnvironment), + [normalizedToken, selectedAppEnvironment, snapshot?.share.sharePath], + ); const sortedAllowedManagementApps = useMemo( () => [...allowedManagementApps].sort((left, right) => ( compareShareAppLaunchOrder(left.value, right.value, left.usagePriority, right.usagePriority, appLaunchUsage) @@ -3110,6 +3410,7 @@ export function ChatSharePage() { const hasWorkServerCommandApp = shareAllowedAppIdSet.has('server-command'); const canManageSharedTokenSetting = sharePermissionSet.has('manage') && shareAllowedAppIdSet.has('token-setting'); const canManageSharedRoomSettings = sharePermissionSet.has('manage') && hasSharedRoomSettingsApp; + const canCreateSharedRooms = sharePermissionSet.has('manage'); const canEditSharedRoomAccessPin = hasSharedRoomSettingsApp; const canOpenSharedRoomSettings = hasSharedRoomSettingsApp; const isImmediateSendPinned = normalizedToken ? immediateSendPinnedByToken[normalizedToken] === true : false; @@ -3477,6 +3778,10 @@ export function ChatSharePage() { () => resolveChatRoomContextSettings(roomContexts, snapshot?.conversation.sessionId ?? null), [roomContexts, snapshot?.conversation.sessionId], ); + const sortedRequests = useMemo( + () => [...(snapshot?.requests ?? [])].sort(compareShareConversationRequests), + [snapshot], + ); const minimizedProgramTarget = programMinimizedTarget ?? programTarget; const openSharedRoomSettings = useCallback(() => { if (!snapshot?.conversation.sessionId) { @@ -3516,6 +3821,41 @@ export function ChatSharePage() { snapshot?.conversation.sessionId, snapshot?.targetRequest.userText, ]); + const openCreateRoomDialog = useCallback(() => { + const nextChatTypeId = currentSharedChatTypeId ?? enabledChatTypes[0]?.id ?? null; + const nextChatTypeName = enabledChatTypes.find((item) => item.id === nextChatTypeId)?.name ?? ''; + + setCreatingRoomTitle(nextChatTypeName ? `${nextChatTypeName} 작업방` : '새 공유 채팅방'); + setCreatingRoomChatTypeId(nextChatTypeId); + setCreatingRoomRequestBadgeLabel(''); + setCreatingRoomSeedMessage('이 방에서 이어갈 작업 내용을 남겨 주세요. 필요한 파일이나 참고 문맥이 있으면 함께 적어 주세요.'); + setIsCreateRoomOpen(true); + }, [currentSharedChatTypeId, enabledChatTypes]); + const refreshShareRuntime = useCallback(async (options?: { silent?: boolean }) => { + if (!normalizedToken || !selectedShareRoomSessionId) { + setShareRuntimeSnapshot(null); + return false; + } + + if (!options?.silent) { + setIsShareRuntimeLoading(true); + } + + try { + const nextSnapshot = await fetchChatShareRuntimeSnapshot(normalizedToken, { + sessionId: selectedShareRoomSessionId, + }); + setShareRuntimeSnapshot(nextSnapshot); + return true; + } catch (error) { + if (!options?.silent) { + message.error(error instanceof Error ? error.message : '처리중 세션 상태를 불러오지 못했습니다.'); + } + return false; + } finally { + setIsShareRuntimeLoading(false); + } + }, [message, normalizedToken, selectedShareRoomSessionId]); useEffect(() => { if (!isRoomSettingsOpen) { return; @@ -3523,6 +3863,270 @@ export function ChatSharePage() { void refreshRoomNotificationStatus(editingRoomNotifyOffline); }, [appConfig.chat.receiveRoomNotifications, editingRoomNotifyOffline, isRoomSettingsOpen, refreshRoomNotificationStatus]); + useEffect(() => { + if (!isRoomSettingsOpen || roomSettingsTabKey !== 'runtime') { + return; + } + + void refreshShareRuntime(); + }, [isRoomSettingsOpen, refreshShareRuntime, roomSettingsTabKey]); + const refreshSnapshot = useCallback(async (options?: { initialLoad?: boolean; silent?: boolean; sharePin?: string | null }) => { + if (!normalizedToken) { + return false; + } + + const initialLoad = options?.initialLoad === true; + const silent = options?.silent === true; + + if (snapshotRefreshPromiseRef.current) { + if (!initialLoad) { + pendingSilentRefreshRef.current = pendingSilentRefreshRef.current || silent; + } + return snapshotRefreshPromiseRef.current; + } + + if (options?.initialLoad) { + setIsLoading(true); + } else { + setIsRefreshing(true); + } + + const refreshTask = (async () => { + try { + const nextSnapshot = await fetchChatShareSnapshot(normalizedToken, { + sharePin: options?.sharePin, + sessionId: requestedRoomSessionIdRef.current || undefined, + }); + + const shouldApplyImmediately = + !isInteractingRef.current || initialLoad || requiresAccessPinRef.current || !hasSnapshotRef.current; + + if (!shouldApplyImmediately) { + deferredSnapshotRef.current = nextSnapshot; + } else { + setSnapshot(nextSnapshot); + deferredSnapshotRef.current = null; + } + if (nextSnapshot.share.hasAccessPin) { + const resolvedPin = normalizeAccessPinInput(options?.sharePin ?? ''); + const persistedPin = resolvedPin || getStoredChatShareAccessPin(normalizedToken); + if (persistedPin) { + setStoredChatShareAccessPin(normalizedToken, persistedPin, { + expiresAt: nextSnapshot.share.accessPinSessionExpiresAt, + ttlMinutes: nextSnapshot.share.accessPinPromptTtlMinutes, + }); + } + } else { + setStoredChatShareAccessPin(normalizedToken, null); + } + setErrorMessage(''); + setRequiresAccessPin(false); + setAccessPinSubmitError(''); + return true; + } catch (error) { + if (error instanceof ChatApiError && error.status === 401 && (error.code === 'share_pin_required' || error.code === 'share_pin_invalid')) { + setStoredChatShareAccessPin(normalizedToken, null); + setRequiresAccessPin(true); + + if (error.code === 'share_pin_invalid') { + setAccessPinInput(''); + setAccessPinSubmitError(error.message); + } else { + setAccessPinSubmitError(''); + } + } + + if (!silent) { + setErrorMessage(error instanceof Error ? error.message : '공유 화면을 불러오지 못했습니다.'); + } + return false; + } finally { + snapshotRefreshPromiseRef.current = null; + setIsLoading(false); + setIsRefreshing(false); + + if (pendingSilentRefreshRef.current) { + pendingSilentRefreshRef.current = false; + window.setTimeout(() => { + void refreshSnapshot({ silent: true }); + }, 0); + } + } + })(); + + snapshotRefreshPromiseRef.current = refreshTask; + return refreshTask; + }, [normalizedToken]); + const handleCancelShareRuntimeRequest = useCallback((item: ChatRuntimeJobItem) => { + const actionLabel = item.status === 'queued' ? '대기 요청' : '실행 중 요청'; + + modal.confirm({ + title: `${actionLabel}을 취소할까요?`, + content: item.summary || '요약 정보가 없는 요청입니다.', + okText: '취소 실행', + cancelText: '닫기', + autoFocusButton: 'cancel', + okButtonProps: { danger: true }, + onOk: async () => { + setPendingShareRuntimeRequestIds((current) => [...current, item.requestId]); + + try { + const action = await cancelChatShareRuntimeRequest(normalizedToken, { + requestId: item.requestId, + sessionId: activeShareRoomSessionId, + }); + message.success(action === 'removed' ? '대기 요청을 취소했습니다.' : '실행 중 요청 취소를 요청했습니다.'); + await Promise.all([ + refreshShareRuntime({ silent: true }), + refreshSnapshot({ silent: true }), + ]); + } catch (error) { + message.error(error instanceof Error ? error.message : '처리중 세션 취소에 실패했습니다.'); + } finally { + setPendingShareRuntimeRequestIds((current) => current.filter((requestId) => requestId !== item.requestId)); + } + }, + }); + }, [activeShareRoomSessionId, message, modal, normalizedToken, refreshShareRuntime, refreshSnapshot]); + const handleCopyShareMessageText = useCallback(async (text: string, label: string) => { + const normalizedText = text.trim(); + + if (!normalizedText) { + message.warning(`복사할 ${label} 내용이 없습니다.`); + return; + } + + try { + await copyTextToClipboard(normalizedText); + message.success(`${label}을 복사했습니다.`); + } catch (error) { + console.error(`failed to copy ${label} text`, error); + message.error(`${label} 복사에 실패했습니다.`); + } + }, [message]); + const handleCancelActiveShareRequest = useCallback(async (requestId: string) => { + const normalizedRequestId = requestId.trim(); + + if (!normalizedToken || !normalizedRequestId || pendingShareRuntimeRequestIds.includes(normalizedRequestId)) { + return; + } + + const targetRequest = sortedRequests.find((item) => item.requestId === normalizedRequestId) ?? null; + + if (!targetRequest || targetRequest.hasResponse || !isRequestInFlight(targetRequest.status)) { + return; + } + + const actionLabel = targetRequest.status === 'queued' ? '대기 요청' : '실행 중 요청'; + const confirmed = await new Promise((resolve) => { + modal.confirm({ + title: `${actionLabel}을 취소할까요?`, + content: targetRequest.userText.trim() || targetRequest.statusMessage?.trim() || '요약 정보가 없는 요청입니다.', + okText: '취소 실행', + cancelText: '닫기', + autoFocusButton: 'cancel', + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + resolve(true); + }, + onCancel: () => { + resolve(false); + }, + }); + }); + + if (!confirmed) { + return; + } + + setPendingShareRuntimeRequestIds((current) => Array.from(new Set([...current, normalizedRequestId]))); + + try { + const action = await cancelChatShareRuntimeRequest(normalizedToken, { + requestId: normalizedRequestId, + sessionId: activeShareRoomSessionId, + }); + message.success(action === 'removed' ? '대기 요청을 취소했습니다.' : '실행 중 요청 취소를 요청했습니다.'); + await Promise.all([ + refreshShareRuntime({ silent: true }), + refreshSnapshot({ silent: true }), + ]); + } catch (error) { + message.error(error instanceof Error ? error.message : '처리 중 요청 취소에 실패했습니다.'); + } finally { + setPendingShareRuntimeRequestIds((current) => current.filter((item) => item !== normalizedRequestId)); + } + }, [activeShareRoomSessionId, message, modal, normalizedToken, pendingShareRuntimeRequestIds, refreshShareRuntime, refreshSnapshot, sortedRequests]); + const handleResubmitQueuedRequestDirect = useCallback(async (requestId: string) => { + const normalizedRequestId = requestId.trim(); + + if (!normalizedToken || !normalizedRequestId || isSending || pendingShareRuntimeRequestIds.includes(normalizedRequestId)) { + return; + } + + const targetRequest = sortedRequests.find((item) => item.requestId === normalizedRequestId) ?? null; + + if (!targetRequest || targetRequest.hasResponse || targetRequest.status !== 'queued') { + return; + } + + const outgoingText = buildOutgoingShareMessageText(targetRequest.userText, []).trim(); + + if (!outgoingText) { + message.warning('즉시 전송할 질문 내용이 없습니다.'); + return; + } + + const confirmed = await new Promise((resolve) => { + modal.confirm({ + title: '대기 요청을 즉시전송할까요?', + content: '기존 대기 요청은 취소하고 같은 내용을 즉시전송으로 다시 보냅니다.', + okText: '즉시전송', + cancelText: '닫기', + centered: true, + onOk: async () => { + resolve(true); + }, + onCancel: () => { + resolve(false); + }, + }); + }); + + if (!confirmed) { + return; + } + + setPendingShareRuntimeRequestIds((current) => Array.from(new Set([...current, normalizedRequestId]))); + setIsSending(true); + + try { + await cancelChatShareRuntimeRequest(normalizedToken, { + requestId: normalizedRequestId, + sessionId: activeShareRoomSessionId, + }); + await submitChatShareMessage(normalizedToken, outgoingText, { + sessionId: activeShareRoomSessionId, + mode: 'direct', + parentRequestId: targetRequest.parentRequestId?.trim() || '', + }); + message.success('대기 요청을 취소하고 즉시전송했습니다.'); + await Promise.all([ + refreshShareRuntime({ silent: true }), + refreshSnapshot({ silent: true }), + ]); + } catch (error) { + if (isShareSendDelayError(error)) { + message.warning('즉시전송 후 응답 확인이 지연되고 있습니다. 연결 복구 시 최신 내용을 다시 불러옵니다.'); + } else { + message.error(error instanceof Error ? error.message : '즉시전송 처리에 실패했습니다.'); + } + } finally { + setIsSending(false); + setPendingShareRuntimeRequestIds((current) => current.filter((item) => item !== normalizedRequestId)); + } + }, [activeShareRoomSessionId, isSending, message, modal, normalizedToken, pendingShareRuntimeRequestIds, refreshShareRuntime, refreshSnapshot, sortedRequests]); const handleSaveSharedRoomSettings = useCallback(async () => { if (!snapshot?.conversation.sessionId) { setIsRoomSettingsOpen(false); @@ -3652,6 +4256,7 @@ export function ChatSharePage() { if (shouldSaveAccessPinSettings || shouldSaveConversationSettings) { const roomSecurity = await saveChatShareRoomSettings(normalizedToken, { + sessionId: snapshot.conversation.sessionId, accessPin: nextAccessPinUpdate, accessPinPromptTtlMinutes: editingRoomUseAccessPin ? editingRoomAccessPinPromptTtlMinutes : null, chatTypeId: shouldSaveConversationSettings ? nextChatType?.id ?? null : undefined, @@ -3692,6 +4297,7 @@ export function ChatSharePage() { message.success(canManageSharedRoomSettings ? '공유 채팅방 설정을 저장했습니다.' : '공유 비밀번호를 저장했습니다.'); setIsEditingRoomDefaultContextsDirty(false); setIsRoomSettingsOpen(false); + void refreshSnapshot({ silent: true }); } catch (error) { message.error(error instanceof Error ? error.message : '공유 채팅방 설정 저장에 실패했습니다.'); } finally { @@ -3718,6 +4324,7 @@ export function ChatSharePage() { message, normalizedToken, roomNotificationClientStatus.tone, + refreshSnapshot, activeRoomContextSettings?.codexParticipants, activeRoomContextSettings?.customContextContent, activeRoomContextSettings?.customContextTitle, @@ -4083,93 +4690,6 @@ export function ChatSharePage() { }; }, [queueScrollJumpVisibilitySync, syncScrollJumpVisibility]); - const refreshSnapshot = useCallback(async (options?: { initialLoad?: boolean; silent?: boolean; sharePin?: string | null }) => { - if (!normalizedToken) { - return false; - } - - const initialLoad = options?.initialLoad === true; - const silent = options?.silent === true; - - if (snapshotRefreshPromiseRef.current) { - if (!initialLoad) { - pendingSilentRefreshRef.current = pendingSilentRefreshRef.current || silent; - } - return snapshotRefreshPromiseRef.current; - } - - if (options?.initialLoad) { - setIsLoading(true); - } else { - setIsRefreshing(true); - } - - const refreshTask = (async () => { - try { - const nextSnapshot = await fetchChatShareSnapshot(normalizedToken, { - sharePin: options?.sharePin, - }); - - const shouldApplyImmediately = - !isInteractingRef.current || initialLoad || requiresAccessPinRef.current || !hasSnapshotRef.current; - - if (!shouldApplyImmediately) { - deferredSnapshotRef.current = nextSnapshot; - } else { - setSnapshot(nextSnapshot); - deferredSnapshotRef.current = null; - } - if (nextSnapshot.share.hasAccessPin) { - const resolvedPin = normalizeAccessPinInput(options?.sharePin ?? ''); - const persistedPin = resolvedPin || getStoredChatShareAccessPin(normalizedToken); - if (persistedPin) { - setStoredChatShareAccessPin(normalizedToken, persistedPin, { - expiresAt: nextSnapshot.share.accessPinSessionExpiresAt, - ttlMinutes: nextSnapshot.share.accessPinPromptTtlMinutes, - }); - } - } else { - setStoredChatShareAccessPin(normalizedToken, null); - } - setErrorMessage(''); - setRequiresAccessPin(false); - setAccessPinSubmitError(''); - return true; - } catch (error) { - if (error instanceof ChatApiError && error.status === 401 && (error.code === 'share_pin_required' || error.code === 'share_pin_invalid')) { - setStoredChatShareAccessPin(normalizedToken, null); - setRequiresAccessPin(true); - - if (error.code === 'share_pin_invalid') { - setAccessPinInput(''); - setAccessPinSubmitError(error.message); - } else { - setAccessPinSubmitError(''); - } - } - - if (!silent) { - setErrorMessage(error instanceof Error ? error.message : '공유 화면을 불러오지 못했습니다.'); - } - return false; - } finally { - snapshotRefreshPromiseRef.current = null; - setIsLoading(false); - setIsRefreshing(false); - - if (pendingSilentRefreshRef.current) { - pendingSilentRefreshRef.current = false; - window.setTimeout(() => { - void refreshSnapshot({ silent: true }); - }, 0); - } - } - })(); - - snapshotRefreshPromiseRef.current = refreshTask; - return refreshTask; - }, [normalizedToken]); - const scheduleSnapshotRefresh = useCallback( (delayMs = 120) => { if (!normalizedToken) { @@ -4199,6 +4719,50 @@ export function ChatSharePage() { return undefined; }, [normalizedToken, refreshSnapshot]); + useEffect(() => { + if (!normalizedToken || !hasSnapshotRef.current) { + return; + } + + const roomSwitchSequence = roomSwitchSequenceRef.current; + void refreshSnapshot({ silent: true }).finally(() => { + if (roomSwitchSequenceRef.current === roomSwitchSequence) { + setIsRoomSwitching(false); + } + }); + }, [normalizedToken, refreshSnapshot, requestedRoomSessionId]); + + useEffect(() => { + if (!requestedRoomSessionId) { + return; + } + + if (shareRooms.some((room) => room.sessionId === requestedRoomSessionId)) { + return; + } + + setRequestedRoomSessionId(''); + }, [requestedRoomSessionId, shareRooms]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const nextUrl = new URL(window.location.href); + const roomSessionId = shareRooms.some((room) => room.sessionId === requestedRoomSessionId) + ? requestedRoomSessionId + : activeShareRoomSessionId; + + if (roomSessionId) { + nextUrl.searchParams.set('roomSessionId', roomSessionId); + } else { + nextUrl.searchParams.delete('roomSessionId'); + } + + window.history.replaceState(null, '', `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`); + }, [activeShareRoomSessionId, requestedRoomSessionId, shareRooms]); + const handleUnlockShare = useCallback(async (inputPin?: string) => { const normalizedPin = normalizeAccessPinInput(inputPin ?? accessPinInput.trim()); @@ -4247,7 +4811,7 @@ export function ChatSharePage() { }, [normalizedToken, snapshot?.share.accessPinSessionExpiresAt, snapshot?.share.hasAccessPin]); useEffect(() => { - const sessionId = snapshot?.share.sessionId?.trim() ?? ''; + const sessionId = selectedShareRoomSessionId; if (!normalizedToken || !sessionId || typeof window === 'undefined' || requiresAccessPin) { return undefined; @@ -4297,7 +4861,6 @@ export function ChatSharePage() { clearDisconnectTimer(); setIsLiveConnected(true); - scheduleSnapshotRefresh(0); }); socket.addEventListener('message', (event) => { @@ -4313,6 +4876,7 @@ export function ChatSharePage() { } if ( + payload.type === 'chat:init' || payload.type === 'chat:status' || payload.type === 'chat:runtime' || payload.type === 'chat:runtime:detail' || @@ -4360,7 +4924,7 @@ export function ChatSharePage() { clearDisconnectTimer(); socket?.close(); }; - }, [normalizedToken, requiresAccessPin, scheduleSnapshotRefresh, snapshot?.share.sessionId]); + }, [normalizedToken, requiresAccessPin, scheduleSnapshotRefresh, selectedShareRoomSessionId]); useEffect(() => { return () => { @@ -4398,6 +4962,7 @@ export function ChatSharePage() { ) => { try { await submitChatSharePrompt(normalizedToken, { + sessionId: activeShareRoomSessionId, parentRequestId: payload.parentRequestId, promptIndex: payload.promptIndex, promptTitle: payload.promptTitle, @@ -4489,6 +5054,7 @@ export function ChatSharePage() { try { await completeChatShareManualBadge(normalizedToken, { + sessionId: activeShareRoomSessionId, parentRequestId: normalizedRequestId, type: 'prompt', }); @@ -4539,6 +5105,7 @@ export function ChatSharePage() { try { const updatedRequest = await completeChatShareManualBadge(normalizedToken, { + sessionId: activeShareRoomSessionId, parentRequestId: normalizedRequestId, type: 'verification', }); @@ -4594,6 +5161,7 @@ export function ChatSharePage() { try { const updatedRequest = await cancelChatShareRequest(normalizedToken, { + sessionId: activeShareRoomSessionId, parentRequestId: normalizedRequestId, }); setSnapshot((current) => (current ? replaceChatShareSnapshotRequest(current, updatedRequest) : current)); @@ -4648,6 +5216,7 @@ export function ChatSharePage() { try { await retryChatShareRequest(normalizedToken, { + sessionId: activeShareRoomSessionId, parentRequestId: normalizedRequestId, }); message.success('중단된 요청 재처리를 시작했습니다. 최신 상태를 다시 불러옵니다.'); @@ -4659,6 +5228,86 @@ export function ChatSharePage() { } }; + const handleSelectShareRoom = useCallback((sessionId: string) => { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return; + } + + if (normalizedSessionId === selectedShareRoomSessionId) { + setIsShareRoomListVisible(false); + return; + } + + roomSwitchSequenceRef.current += 1; + setIsRoomSwitching(true); + setRequestedRoomSessionId(normalizedSessionId); + setIsShareRoomListVisible(false); + }, [selectedShareRoomSessionId]); + + const handleCreateShareRoom = useCallback(async () => { + if (!normalizedToken || isCreatingRoom) { + return; + } + + const nextChatType = enabledChatTypes.find((item) => item.id === creatingRoomChatTypeId) ?? null; + const normalizedTitle = creatingRoomTitle.trim(); + const normalizedSeedMessage = creatingRoomSeedMessage.trim(); + + if (!nextChatType) { + message.warning('새 방에 사용할 채팅유형을 먼저 선택하세요.'); + return; + } + + if (!normalizedTitle) { + message.warning('새 채팅방 이름을 입력하세요.'); + return; + } + + if (!normalizedSeedMessage) { + message.warning('새 채팅방 시작 문구를 입력하세요.'); + return; + } + + setIsCreatingRoom(true); + + try { + const createdRoom = await createChatShareRoom(normalizedToken, { + chatTypeId: nextChatType.id, + chatTypeLabel: nextChatType.name, + title: normalizedTitle, + requestBadgeLabel: creatingRoomRequestBadgeLabel.trim() || null, + seedMessage: normalizedSeedMessage, + }); + + setIsCreateRoomOpen(false); + setOptimisticShareRooms((current) => ( + current.some((room) => room.sessionId === createdRoom.sessionId) + ? current + : [...current, createdRoom] + )); + setRequestedRoomSessionId(createdRoom.sessionId); + setDraftText(''); + setComposerAttachments([]); + setReplyReferenceRequestId(''); + message.success('새 공유 채팅방을 추가했습니다.'); + } catch (error) { + message.error(error instanceof Error ? error.message : '새 공유 채팅방을 추가하지 못했습니다.'); + } finally { + setIsCreatingRoom(false); + } + }, [ + creatingRoomChatTypeId, + creatingRoomRequestBadgeLabel, + creatingRoomSeedMessage, + creatingRoomTitle, + enabledChatTypes, + isCreatingRoom, + message, + normalizedToken, + ]); + const shareKind = snapshot?.share.kind ?? 'request-bundle'; const isPromptShare = shareKind === 'prompt'; @@ -4677,6 +5326,7 @@ export function ChatSharePage() { try { await submitChatShareMessage(normalizedToken, outgoingText, { + sessionId: selectedShareRoomSessionId, mode, parentRequestId: resolvedParentRequestId, }); @@ -4720,6 +5370,7 @@ export function ChatSharePage() { normalizedToken, refreshSnapshot, replyReferenceRequestId, + selectedShareRoomSessionId, shareKind, snapshot?.targetRequest.requestId, ]); @@ -5002,7 +5653,7 @@ export function ChatSharePage() { const handleUploadPromptAttachment = useCallback( async (file: File) => { - const sessionId = snapshot?.share.sessionId?.trim() ?? ''; + const sessionId = selectedShareRoomSessionId; if (!sessionId) { throw new Error('공유 채팅 세션이 준비되지 않았습니다.'); @@ -5010,7 +5661,7 @@ export function ChatSharePage() { return uploadChatShareComposerFile(normalizedToken, sessionId, file); }, - [normalizedToken, snapshot?.share.sessionId], + [normalizedToken, selectedShareRoomSessionId], ); const handleUploadComposerAttachments = useCallback( @@ -5074,7 +5725,7 @@ export function ChatSharePage() { const handleClearConversation = useCallback(async () => { - const sessionId = snapshot?.share.sessionId?.trim() ?? ''; + const sessionId = selectedShareRoomSessionId; const conversationTitle = snapshot?.conversation.title?.trim() || '현재 채팅방'; if (!normalizedToken || !sessionId || isClearingConversation) { @@ -5105,7 +5756,7 @@ export function ChatSharePage() { setIsClearingConversation(true); try { - await clearChatShareConversationRoom(normalizedToken); + await clearChatShareConversationRoom(normalizedToken, sessionId); setDraftText(''); setComposerAttachments([]); setLatestRequestId(''); @@ -5131,7 +5782,7 @@ export function ChatSharePage() { } finally { setIsClearingConversation(false); } - }, [isClearingConversation, message, modal, normalizedToken, snapshot]); + }, [isClearingConversation, message, modal, normalizedToken, selectedShareRoomSessionId, snapshot]); const shareBlockedReason = snapshot?.share.blockedReason?.trim() ?? ''; const canSendMessage = snapshot != null && !isPromptShare && (snapshot.share.canSendMessage ?? true); @@ -5147,10 +5798,6 @@ export function ChatSharePage() { }) ?? null; const promptTarget = snapshot?.promptTarget ?? null; const promptTargetRequestId = snapshot?.targetRequest?.requestId?.trim() ?? ''; - const sortedRequests = useMemo( - () => [...(snapshot?.requests ?? [])].sort(compareShareConversationRequests), - [snapshot], - ); const requestMessagesById = useMemo(() => { const nextMap = new Map(); @@ -5366,19 +6013,6 @@ export function ChatSharePage() { return summarizeShareReplyReferenceText(answerText || replyReferenceRequest.userText || '선택한 답변'); }, [replyReferenceRequest, requestAnswerTextById]); - const shareSendModeSummary = useMemo(() => { - if (isImmediateSendPinned) { - return { - tagLabel: '즉시전송 고정', - description: '전송 버튼과 Ctrl+Enter가 즉시전송으로 동작합니다. 번개 버튼을 1초 이상 눌러 해제할 수 있습니다.', - }; - } - - return { - tagLabel: '일반 전송 대기열', - description: '기본 전송은 대기열에 등록됩니다. 즉시전송은 번개 버튼을 누르거나 1초 이상 눌러 고정했을 때만 실행됩니다.', - }; - }, [isImmediateSendPinned]); const previousQuestionModalRequest = useMemo( () => (previousQuestionModalRequestId.trim() ? requestById.get(previousQuestionModalRequestId.trim()) ?? null : null), [previousQuestionModalRequestId, requestById], @@ -5490,12 +6124,17 @@ export function ChatSharePage() { const contentLayoutClassName = canSendMessage ? 'chat-share-page__content-layout chat-share-page__content-layout--with-composer' : 'chat-share-page__content-layout'; + const canToggleShareRoomList = shareRooms.length > 1 || canCreateSharedRooms; const canLaunchShareProgram = useCallback( (appId?: ShareProgramTarget['appId']) => { if (!appId) { return true; } + if (appId === SHARE_CURRENT_CHAT_APP_ID) { + return true; + } + return shareAllowedAppIdSet.has(appId); }, [shareAllowedAppIdSet], @@ -5630,6 +6269,28 @@ export function ChatSharePage() { if (searchPanelMode === 'apps') { const photoprismLauncher = buildPhotoPrismProgramTarget(); + if ( + matchesSearchKeyword( + keyword, + currentShareChatTarget.label, + currentShareChatTarget.appId, + '공유채팅', + '현재 토큰', + '현재 공유토큰 열기', + ...APPS_LAUNCHER_SEARCH_TERMS, + ) + ) { + results.push({ + key: `management-app:${SHARE_CURRENT_CHAT_APP_ID}`, + title: currentShareChatTarget.label, + description: `현재 공유토큰을 ${selectedAppEnvironment} 환경에서 다시 엽니다.`, + category: 'resource', + icon: , + usageBadge: resolveShareAppUsageBadge(appLaunchUsage[SHARE_CURRENT_CHAT_APP_ID]), + resource: currentShareChatTarget, + }); + } + sortedAllowedManagementApps.forEach((item) => { if (!matchesSearchKeyword(keyword, item.value, item.label, item.description, ...APPS_LAUNCHER_SEARCH_TERMS)) { return; @@ -5813,7 +6474,7 @@ export function ChatSharePage() { return results .filter((item) => !item.scrollTarget || item.scrollTarget.value.trim()) .slice(0, 40); - }, [appLaunchUsage, messageRenderPayloadById, normalizedToken, searchKeyword, searchPanelMode, snapshot?.activityLogs, snapshot?.rootRequestId, sortedAllowedManagementApps, sortedAllowedPlayAppEntries, sortedMessages, sortedRequests]); + }, [appLaunchUsage, currentShareChatTarget, messageRenderPayloadById, normalizedToken, searchKeyword, searchPanelMode, selectedAppEnvironment, snapshot?.activityLogs, snapshot?.rootRequestId, sortedAllowedManagementApps, sortedAllowedPlayAppEntries, sortedMessages, sortedRequests]); const selectedTokenUsageSetting = shareTokenSetting; const tokenUsageSummaryByPeriod = useMemo( () => @@ -5989,7 +6650,7 @@ export function ChatSharePage() { ), icon: , }, - ...(allowedManagementApps.length > 0 || allowedPlayAppEntries.length > 0 + ...(normalizedToken || allowedManagementApps.length > 0 || allowedPlayAppEntries.length > 0 ? [ { key: 'conversation-apps', @@ -6055,6 +6716,22 @@ export function ChatSharePage() { }, ] : []), + ...(canCreateSharedRooms + ? [ + { + key: 'conversation-room-create', + label: ( + + 채팅방 추가 + + 같은 공유 토큰 안에 새 채팅방을 만들고 바로 전환합니다. + + + ), + icon: , + }, + ] + : []), ...(hasWorkServerCommandApp ? [ { @@ -6097,7 +6774,7 @@ export function ChatSharePage() { ), icon: , danger: true, - disabled: !canSendMessage || isClearingConversation || !(snapshot?.share.sessionId?.trim()), + disabled: !canSendMessage || isClearingConversation || !selectedShareRoomSessionId, }, ], [ @@ -6105,12 +6782,14 @@ export function ChatSharePage() { allowedPlayAppEntries.length, canSendMessage, canOpenSharedRoomSettings, + canCreateSharedRooms, hasWorkServerCommandApp, isClearingConversation, + normalizedToken, selectedTokenUsageSetting, shareWorkServerCommand, snapshot?.conversation.title, - snapshot?.share.sessionId, + selectedShareRoomSessionId, tokenUsageFiveHourSummary.percentage, tokenUsageOverview.currentAvailableLabel, tokenUsageOverview.fiveHourCountdownLabel, @@ -6147,6 +6826,11 @@ export function ChatSharePage() { return; } + if (key === 'conversation-room-create') { + openCreateRoomDialog(); + return; + } + if (key === 'conversation-work-server-command') { openProgramTarget(buildShareManagementProgramTarget('server-command', '서버관리')); return; @@ -6156,7 +6840,7 @@ export function ChatSharePage() { void handleClearConversation(); } }, - [handleClearConversation, handleReloadPage, openProgramTarget, openSharedRoomSettings], + [handleClearConversation, handleReloadPage, openCreateRoomDialog, openProgramTarget, openSharedRoomSettings], ); const shareExpandModeMenuItems = useMemo( () => [ @@ -6429,7 +7113,7 @@ export function ChatSharePage() { sourceMessageId: promptTarget.sourceMessageId, }) } - allowAttachments={Boolean(snapshot?.share.sessionId?.trim())} + allowAttachments={Boolean(selectedShareRoomSessionId)} attachmentAccept={SHARE_ATTACHMENT_ACCEPT} onUploadAttachment={handleUploadPromptAttachment} /> @@ -6463,9 +7147,10 @@ export function ChatSharePage() { isVerificationCompletionSaving={pendingVerificationCompletionRequestIds.includes(promptTargetRequestId)} isVerificationCompleted={Boolean(snapshot.targetRequest.manualVerificationCompletedAt)} shareToken={normalizedToken} - canUploadAttachments={Boolean(snapshot?.share.sessionId?.trim())} + canUploadAttachments={Boolean(selectedShareRoomSessionId)} onUploadAttachment={handleUploadPromptAttachment} onSetResponseAnchor={setResponseAnchorRef} + onCopyMessage={handleCopyShareMessageText} onSetPromptAnchor={setPromptAnchorRef} /> @@ -6506,7 +7191,7 @@ export function ChatSharePage() { onReplyToResponse={null} shareToken={normalizedToken} onOpenProgram={openProgramTarget} - canUploadAttachments={Boolean(snapshot?.share.sessionId?.trim())} + canUploadAttachments={Boolean(selectedShareRoomSessionId)} onUploadAttachment={handleUploadPromptAttachment} onSetRequestAnchor={setRequestAnchorRef} onSetResponseAnchor={setResponseAnchorRef} @@ -6519,6 +7204,47 @@ export function ChatSharePage() {
) : (
+ {canToggleShareRoomList && isShareRoomListVisible ? ( +
+
+
+ 채팅방 + + {activeShareRoom ? `${activeShareRoom.title} 사용 중` : '공유 토큰에 연결된 방 목록'} + +
+ {canCreateSharedRooms ? ( + + ) : null} +
+
+ {shareRooms.map((room) => { + const isActive = room.sessionId === selectedShareRoomSessionId; + + return ( + + ); + })} +
+
+ ) : null}
@@ -6536,6 +7262,19 @@ export function ChatSharePage() {
+ {canToggleShareRoomList ? ( +
-
- {headerInquiryRequest ? ( -
-
-
-
- - {headerTitleText} - - {canOpenSharedRoomSettings ? ( -
-
- - - + + +
+ + ) : null} + {expandMode === 'latest' && hiddenBeforeCount > 0 ? ( +
+
- - ) : null} - {expandMode === 'latest' && hiddenBeforeCount > 0 ? ( -
-
- ) : null} - {displayedRequests.map((request) => ( - 0} - activeReplyRequestId={replyReferenceRequestId} - onCancelDisconnectedRequest={handleCancelDisconnectedRequest} - isRequestCancellationSaving={pendingRequestCancellationIds.includes(request.requestId)} - onRetryDisconnectedRequest={handleRetryDisconnectedRequest} - isRequestRetrySaving={pendingRequestRetryIds.includes(request.requestId)} - onReplyToResponse={ - isPromptShare - ? null - : (parentRequestId) => { - setReplyReferenceRequestId(parentRequestId.trim()); - window.setTimeout(() => { - composerRef.current?.focus({ cursor: 'end' }); - }, 0); - } - } - shareToken={normalizedToken} - onOpenProgram={openProgramTarget} - canUploadAttachments={Boolean(snapshot?.share.sessionId?.trim())} - onUploadAttachment={handleUploadPromptAttachment} - onSetRequestAnchor={setRequestAnchorRef} - onSetResponseAnchor={setResponseAnchorRef} - onSetPromptAnchor={setPromptAnchorRef} - onOpenPreviousQuestion={(requestId) => { - setPreviousQuestionModalRequestId(requestId.trim()); - }} - /> - ))} - {expandMode === 'latest' && hiddenAfterCount > 0 ? ( -
-
- ) : null} - {expandMode === 'pending' && displayedRequests.length === 0 ? ( -
-
- ) : null} - + ) : null} + {displayedRequests.map((request) => ( + 0} + activeReplyRequestId={replyReferenceRequestId} + onCancelDisconnectedRequest={handleCancelDisconnectedRequest} + isRequestCancellationSaving={pendingRequestCancellationIds.includes(request.requestId)} + onRetryDisconnectedRequest={handleRetryDisconnectedRequest} + isRequestRetrySaving={pendingRequestRetryIds.includes(request.requestId)} + onReplyToResponse={ + isPromptShare + ? null + : (parentRequestId) => { + setReplyReferenceRequestId(parentRequestId.trim()); + window.setTimeout(() => { + composerRef.current?.focus({ cursor: 'end' }); + }, 0); + } + } + shareToken={normalizedToken} + onOpenProgram={openProgramTarget} + canUploadAttachments={Boolean(selectedShareRoomSessionId)} + onUploadAttachment={handleUploadPromptAttachment} + onSetRequestAnchor={setRequestAnchorRef} + onSetResponseAnchor={setResponseAnchorRef} + onSetPromptAnchor={setPromptAnchorRef} + onOpenPreviousQuestion={(requestId) => { + setPreviousQuestionModalRequestId(requestId.trim()); + }} + onCopyMessage={handleCopyShareMessageText} + onCancelActiveRequest={handleCancelActiveShareRequest} + isActiveRequestCancellationSaving={pendingShareRuntimeRequestIds.includes(request.requestId)} + onResubmitRequestDirect={handleResubmitQueuedRequestDirect} + isDirectResubmitSaving={pendingShareRuntimeRequestIds.includes(request.requestId) || isSending} + /> + ))} + {expandMode === 'latest' && hiddenAfterCount > 0 ? ( +
+
+ ) : null} + {expandMode === 'pending' && displayedRequests.length === 0 ? ( +
+
+ ) : null} + + )} {canSendMessage ? ( <> - {expandMode === 'latest' && collapsedActivitySummary.length > 0 ? ( + {!isRoomSwitching && expandMode === 'latest' && collapsedActivitySummary.length > 0 ? (
) : null}
-
+ {isRoomSwitching ? ( +
+ +
+ ) : ( +
-
- {shareSendModeSummary.tagLabel} - - {shareSendModeSummary.description} - -
{replyReferenceRequest ? (
@@ -6865,7 +7615,8 @@ export function ChatSharePage() { ))}
) : null} -
+ + )}
) : !isPromptShare && shareBlockedReason ? ( @@ -7321,10 +8072,210 @@ export function ChatSharePage() { ), }, + { + key: 'runtime', + label: `처리중 세션${shareRuntimeRunningItems.length + shareRuntimeQueuedItems.length > 0 ? ` (${shareRuntimeRunningItems.length + shareRuntimeQueuedItems.length})` : ''}`, + children: ( +
+
+ 현재 방 실행 상태 + 이 공유채팅방의 실행중·대기 요청만 따로 보고 바로 취소할 수 있습니다. +
+
+
+ {shareRuntimeRunningItems.length}건 + 실행중 +
+
+ {shareRuntimeQueuedItems.length}건 + 대기중 +
+
+ {shareRuntimeRecentItems.length}건 + 최근 종료 +
+
+ {formatShareRuntimeTimestamp(shareRuntimeSnapshot?.generatedAt)} + 마지막 확인 +
+
+
+ +
+ {shareRuntimeRunningItems.length === 0 && shareRuntimeQueuedItems.length === 0 ? ( + + ) : null} + {shareRuntimeRunningItems.length > 0 ? ( +
+
+ 실행중 +
+
+ {shareRuntimeRunningItems.map((item) => { + const statusTag = resolveShareRuntimeStatusTag(item); + const isPending = pendingShareRuntimeRequestIds.includes(item.requestId); + + return ( +
+
+ {statusTag.label} + {formatElapsedDuration(item.startedAt ?? item.enqueuedAt, nowMs) || '-'} +
+ {item.summary || '요약 정보 없음'} + {`요청 ${item.requestId}`} + {`시작 ${formatShareRuntimeTimestamp(item.startedAt ?? item.enqueuedAt)}`} +
+ +
+
+ ); + })} +
+
+ ) : null} + {shareRuntimeQueuedItems.length > 0 ? ( +
+
+ 대기중 +
+
+ {shareRuntimeQueuedItems.map((item) => { + const statusTag = resolveShareRuntimeStatusTag(item); + const isPending = pendingShareRuntimeRequestIds.includes(item.requestId); + + return ( +
+
+ {statusTag.label} + {formatElapsedDuration(item.enqueuedAt, nowMs) || '-'} +
+ {item.summary || '요약 정보 없음'} + {`요청 ${item.requestId}`} + {`대기 시작 ${formatShareRuntimeTimestamp(item.enqueuedAt)}`} +
+ +
+
+ ); + })} +
+
+ ) : null} + {shareRuntimeRecentItems.length > 0 ? ( +
+
+ 최근 종료 +
+
+ {shareRuntimeRecentItems.map((item) => { + const terminalTag = resolveShareRuntimeTerminalTag(item.terminalStatus); + + return ( +
+
+ {terminalTag.label} + {formatShareRuntimeTimestamp(item.lastUpdatedAt)} +
+ {item.summary || '요약 정보 없음'} + {`요청 ${item.requestId}`} +
+ ); + })} +
+
+ ) : null} + {!canManageSharedRoomSettings ? ( + + ) : null} +
+ ), + }, ]} /> + { + void handleCreateShareRoom(); + }} + onCancel={() => { + if (isCreatingRoom) { + return; + } + setIsCreateRoomOpen(false); + }} + > +
+ + + +
+
{result.usageBadge ? ( @@ -7469,6 +8422,12 @@ export function ChatSharePage() { src={programTarget.url} className="app-chat-panel__preview-frame" /> + ) : programTarget.appId === SHARE_CURRENT_CHAT_APP_ID ? ( +