From 10805d242e84a3545312afa7b83b535b1f428e0b Mon Sep 17 00:00:00 2001 From: how2ice Date: Wed, 27 May 2026 16:35:12 +0900 Subject: [PATCH] chore: test deploy snapshot --- etc/servers/work-server/src/routes/chat.ts | 103 ++- .../src/services/chat-service.test.ts | 57 ++ .../work-server/src/services/chat-service.ts | 33 + .../services/chat-share-room-map-service.ts | 117 ++++ src/app/main/mainChatPanel/chatUtils.ts | 23 + src/app/main/pages/ChatSharePage.css | 124 +++- src/app/main/pages/ChatSharePage.tsx | 647 ++++++++++++++++-- 7 files changed, 1032 insertions(+), 72 deletions(-) diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index 84de99a..f3a31cb 100644 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -50,6 +50,7 @@ import { hasPendingAttentionVerificationRequest, } from '../services/chat-room-service.js'; import { + archiveChatShareTokenRoomMap, ensureDefaultChatShareTokenRoomMap, getChatShareTokenRoomMap, resolveChatShareTokenRoomSessionIds, @@ -1142,12 +1143,13 @@ async function buildChatShareSnapshot( } const childRequestIdsByParentRequestId = buildChildRequestIdsByParentRequestId(requests); - const isManagedShareRoomPlaceholder = - tokenPayload.kind === 'request-bundle' && normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX) && !targetRequestFromStore; + const isManagedShareRoomSession = + tokenPayload.kind === 'request-bundle' && normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX); + const isManagedShareRoomPlaceholder = isManagedShareRoomSession && !targetRequestFromStore; const rootRequestId = isManagedShareRoomPlaceholder ? targetRequestId : resolveShareRootRequestId(targetRequestId, requestMap, tokenPayload.kind); - const scopeRequestIds = isManagedShareRoomPlaceholder + const scopeRequestIds = isManagedShareRoomSession ? requests.map((request) => request.requestId.trim()).filter(Boolean) : collectShareScopeRequestIds(rootRequestId, childRequestIdsByParentRequestId); const scopeRequestIdSet = new Set(scopeRequestIds); @@ -2524,6 +2526,101 @@ export async function registerChatRoutes(app: FastifyInstance) { }; }); + app.delete(`${CHAT_SHARE_ROUTE_PREFIX}/:token/rooms/:sessionId`, async (request, reply) => { + const params = z.object({ + token: z.string().trim().min(1).max(16000), + sessionId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + const managedContext = await resolveManagedChatShareContext(params.token); + const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); + + if (!tokenPayload) { + return reply.code(404).send({ + message: '공유 링크가 유효하지 않습니다.', + }); + } + + if (tokenPayload.kind === 'prompt') { + return reply.code(400).send({ + message: 'prompt 공유 링크에서는 채팅방을 삭제할 수 없습니다.', + }); + } + + 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: '이 공유 링크에는 채팅방을 삭제할 권한이 없습니다.', + }); + } + + if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) { + return reply.code(403).send({ + message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.', + }); + } + + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: params.sessionId, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + if (resolvedRoomContext.rooms.length <= 1) { + return reply.code(400).send({ + message: '마지막 채팅방은 삭제할 수 없습니다.', + }); + } + + getActiveChatService()?.forgetSession(params.sessionId); + chatRuntimeService.clearSession(params.sessionId); + const deleted = await deleteChatConversation(params.sessionId); + const archived = await archiveChatShareTokenRoomMap(managedContext.managedResource.token.id, params.sessionId); + + if (!deleted || !archived.archived) { + return reply.code(404).send({ + message: '삭제할 채팅방을 찾을 수 없습니다.', + }); + } + + if (managedContext.managedResource) { + await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { + actorLabel: 'share-manager', + summary: '공유 채팅방을 삭제했습니다.', + detail: params.sessionId, + }); + } + + return { + ok: true, + deleted: true, + deletedSessionId: params.sessionId, + nextRoomSessionId: archived.nextDefaultRoom?.sessionId ?? null, + }; + }); + app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/messages`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts index 1c1487f..74f2038 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -453,6 +453,63 @@ test('buildAgenticCodexPrompt keeps the chat type label provided by the client c assert.doesNotMatch(prompt, /- label: 코드 수정/); }); +test('buildAgenticCodexPrompt pins the explicitly referenced answer ahead of recent history', () => { + const prompt = buildAgenticCodexPrompt( + { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live', + chatTypeLabel: '일반 요청', + chatTypeDescription: '일반 요청 설명', + }, + '지금 무슨 답변을 참조했나요?', + 'session-reference', + { + recentHistoryLines: [ + '[user] 예전 질문', + '[codex] 다른 답변', + ], + referencedRequest: { + sessionId: 'session-reference', + requestId: 'request-123', + requesterClientId: null, + chatTypeId: 'general-request', + chatTypeLabel: '일반 요청', + requestOrigin: 'composer', + sharedResourceTokenId: null, + parentRequestId: null, + promptContextRef: null, + status: 'completed', + statusMessage: '요청 처리 완료', + retryCount: 0, + userMessageId: 1, + userText: 'preview서버에서 test서버 채팅이 안열리는데 test서버 배포 해야돼?', + responseMessageId: 2, + responseText: '배포 누락이 아니라 iframe 차단입니다.', + usageSnapshot: null, + totalTokens: null, + hasResponse: true, + canDelete: false, + manualPromptCompletedAt: null, + manualVerificationCompletedAt: null, + createdAt: '2026-05-27T14:50:23.000Z', + updatedAt: '2026-05-27T14:51:00.000Z', + answeredAt: '2026-05-27T14:51:00.000Z', + terminalAt: '2026-05-27T14:51:00.000Z', + }, + }, + ); + + assert.match(prompt, /## 답변 참조/); + assert.match(prompt, /참조 requestId: request-123/); + assert.match(prompt, /참조 사용자 요청: preview서버에서 test서버 채팅이 안열리는데 test서버 배포 해야돼\?/); + assert.match(prompt, /참조 답변 본문: 배포 누락이 아니라 iframe 차단입니다\./); + assert.match(prompt, /다른 최근 답변을 임의로 섞지 마세요\./); + assert.ok(prompt.indexOf('## 답변 참조') < prompt.indexOf('최근 대화 문맥(보조 참조)')); +}); + test('resolveCodexParticipantsForExecution expands moderator into opening and closing turns', () => { const participants = resolveCodexParticipantsForExecution({ pageId: null, diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index ad37b80..0b8d9b0 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -2804,6 +2804,30 @@ function buildChatSessionReferenceInstructionBlock(referenceContent?: string | n ]; } +function buildReferencedRequestInstructionBlock(referencedRequest?: ChatConversationRequestItem | null) { + if (!referencedRequest) { + return []; + } + + const requestId = referencedRequest.requestId.trim(); + const referencedQuestion = normalizeChatPromptHistoryText(referencedRequest.userText || ''); + const referencedAnswer = normalizeChatPromptHistoryText( + referencedRequest.responseText || referencedRequest.statusMessage || '', + ); + + return [ + '## 답변 참조', + '- 현재 요청은 아래 이전 답변을 명시적으로 선택해 이어가는 후속 요청입니다.', + '- 최근 대화 일반 문맥보다 아래 참조 답변을 우선해서 해석하세요.', + requestId ? `- 참조 requestId: ${requestId}` : null, + referencedQuestion ? `- 참조 사용자 요청: ${normalizePromptContextText(referencedQuestion, 1200)}` : null, + referencedAnswer + ? `- 참조 답변 본문: ${normalizePromptContextText(referencedAnswer, 4000)}` + : '- 참조 답변 본문: 아직 저장된 답변 본문이 없어 상위 요청만 참조합니다.', + '- 사용자가 "방금 어떤 답변을 참조했냐"처럼 물으면 위 requestId와 참조 본문을 기준으로 답하고, 다른 최근 답변을 임의로 섞지 마세요.', + ].filter((line): line is string => Boolean(line)); +} + export function buildAgenticCodexPrompt( context: ChatContext | null, input: string, @@ -2814,6 +2838,7 @@ export function buildAgenticCodexPrompt( omittedHistoryCount?: number; sessionReferenceResourcePath?: string; sessionReferenceContent?: string; + referencedRequest?: ChatConversationRequestItem | null; }, ) { const repoPath = promptContext?.repoPath?.trim() || resolveMainProjectRoot(); @@ -2847,6 +2872,7 @@ export function buildAgenticCodexPrompt( ...buildChatSessionReferenceInstructionBlock(promptContext?.sessionReferenceContent), '', ...buildChatTypeInstructionBlock(context), + ...(promptContext?.referencedRequest ? ['', ...buildReferencedRequestInstructionBlock(promptContext.referencedRequest)] : []), '', '응답 규칙:', '- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.', @@ -3024,6 +3050,7 @@ async function runAgenticCodexReply( options?: { omitPromptHistory?: boolean; requestedAt?: Date | null; + parentRequestId?: string | null; }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, @@ -3032,6 +3059,9 @@ async function runAgenticCodexReply( const repoPath = resolveMainProjectRoot(); await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN); const resolvedContext = await resolveCodexLiveChatContext(context, sessionId); + const referencedRequest = options?.parentRequestId?.trim() + ? await getChatConversationRequest(sessionId, options.parentRequestId.trim()) + : null; const appConfig = await getAppConfigSnapshot(); const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, { maxMessages: appConfig.chat?.maxContextMessages, @@ -3090,6 +3120,7 @@ async function runAgenticCodexReply( omittedHistoryCount: recentHistory.omittedCount, sessionReferenceResourcePath, sessionReferenceContent, + referencedRequest, }); let streamedOutput = ''; let stdoutTail = ''; @@ -3840,6 +3871,7 @@ async function buildCodexReply( options?: { omitPromptHistory?: boolean; requestedAt?: Date | null; + parentRequestId?: string | null; }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, @@ -6143,6 +6175,7 @@ export class ChatService { { omitPromptHistory: request.omitPromptHistory === true, requestedAt: new Date(request.requestedAtMs), + parentRequestId: request.parentRequestId, }, (partialReply) => { stopProgressTimer(); 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 index 43dc9aa..be6e185 100644 --- 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 @@ -310,3 +310,120 @@ export async function resolveChatShareTokenRoomSessionIds(tokenId: string) { const rooms = await listChatShareTokenRoomMaps(tokenId); return rooms.map((item) => item.sessionId).filter(Boolean); } + +export async function archiveChatShareTokenRoomMap(tokenId: string, sessionId: string) { + const normalizedTokenId = tokenId.trim(); + const normalizedSessionId = sessionId.trim(); + + if (!normalizedTokenId || !normalizedSessionId) { + return { + archived: false, + archivedRoom: null, + nextDefaultRoom: null, + } as const; + } + + await ensureChatShareTokenRoomMapTable(); + + return db.transaction(async (trx) => { + const current = await trx(`${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, + 'room_map.session_id': normalizedSessionId, + }) + .whereNull('room_map.archived_at') + .first(); + + if (!current) { + return { + archived: false, + archivedRoom: null, + nextDefaultRoom: null, + } as const; + } + + await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) + .where({ + shared_resource_token_id: normalizedTokenId, + session_id: normalizedSessionId, + }) + .whereNull('archived_at') + .update({ + archived_at: db.fn.now(), + updated_at: db.fn.now(), + }); + + let nextDefaultRoom: ChatShareTokenRoomMapItem | null = null; + + if (current.is_default) { + const nextDefaultRow = await trx(`${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.sort_order', 'asc') + .orderBy('room_map.created_at', 'asc') + .first(); + + if (nextDefaultRow) { + await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) + .where({ + shared_resource_token_id: normalizedTokenId, + session_id: nextDefaultRow.session_id, + }) + .whereNull('archived_at') + .update({ + is_default: true, + updated_at: db.fn.now(), + }); + + nextDefaultRoom = mapChatShareTokenRoomRow({ + ...nextDefaultRow, + is_default: true, + }); + } + } + + return { + archived: true, + archivedRoom: mapChatShareTokenRoomRow(current), + nextDefaultRoom, + } as const; + }); +} diff --git a/src/app/main/mainChatPanel/chatUtils.ts b/src/app/main/mainChatPanel/chatUtils.ts index 43f4525..3c44bdb 100644 --- a/src/app/main/mainChatPanel/chatUtils.ts +++ b/src/app/main/mainChatPanel/chatUtils.ts @@ -2371,6 +2371,29 @@ export async function createChatShareRoom( } satisfies ChatShareRoomSummary; } +export async function deleteChatShareRoom(token: string, sessionId: string) { + const response = await requestChatApi<{ + ok: boolean; + deleted: boolean; + deletedSessionId: string; + nextRoomSessionId?: string | null; + }>( + `/shares/${encodeURIComponent(token)}/rooms/${encodeURIComponent(sessionId)}`, + { + method: 'DELETE', + }, + { + allowUnauthenticated: true, + }, + ); + + return { + deleted: response.deleted === true, + deletedSessionId: normalizeRequiredText(response.deletedSessionId), + nextRoomSessionId: normalizeOptionalText(response.nextRoomSessionId), + }; +} + 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)}`, diff --git a/src/app/main/pages/ChatSharePage.css b/src/app/main/pages/ChatSharePage.css index 208c150..566ee8f 100644 --- a/src/app/main/pages/ChatSharePage.css +++ b/src/app/main/pages/ChatSharePage.css @@ -211,21 +211,75 @@ } .chat-share-page__room-list-panel { + display: grid; + gap: 8px; 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-filter-input.ant-input-affix-wrapper { + width: 100%; + border-radius: 12px; + border-color: rgba(191, 204, 220, 0.9); + background: rgba(255, 255, 255, 0.96); + box-shadow: inset 0 0 0 1px rgba(241, 245, 249, 0.78); +} + .chat-share-page__room-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + display: flex; + flex-direction: column; gap: 8px; + width: 100%; + min-width: 0; +} + +.chat-share-page__room-swipe { + position: relative; + overflow: hidden; + width: 100%; + border-radius: 14px; + isolation: isolate; + -webkit-tap-highlight-color: transparent; +} + +.chat-share-page__room-delete-action { + position: absolute; + inset: 0 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 96px; + border: 0; + border-radius: 14px; + background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%); + color: #fff; + font-size: 1rem; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.2s ease, visibility 0.2s ease; +} + +.chat-share-page__room-swipe.is-swiped .chat-share-page__room-delete-action, +.chat-share-page__room-swipe.is-dragging .chat-share-page__room-delete-action { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.chat-share-page__room-swipe.is-delete-locked .chat-share-page__room-delete-action { + display: none; } .chat-share-page__room-card { display: grid; gap: 6px; + position: relative; + z-index: 1; + width: 100%; + min-width: 0; padding: 12px; border: 0; border-radius: 14px; @@ -235,6 +289,11 @@ 0 6px 18px rgba(148, 163, 184, 0.08); text-align: left; cursor: pointer; + transition: box-shadow 0.2s ease, background 0.2s ease, transform 0.16s ease; + touch-action: pan-y; + will-change: transform; + backface-visibility: hidden; + transform: translate3d(0, 0, 0); } .chat-share-page__room-card--active { @@ -244,6 +303,26 @@ 0 10px 24px rgba(59, 130, 246, 0.16); } +.chat-share-page__room-swipe.is-dragging .chat-share-page__room-card { + transition: box-shadow 0.2s ease, background 0.2s ease; +} + +.chat-share-page__room-card--default { + background: + linear-gradient(180deg, #f4fbff 0%, #e3f4ff 100%); + box-shadow: + inset 0 0 0 1px rgba(34, 211, 238, 0.22), + 0 8px 20px rgba(14, 165, 233, 0.1); +} + +.chat-share-page__room-card--default.chat-share-page__room-card--active { + background: + linear-gradient(180deg, #dff7ff 0%, #d2f0ff 100%); + box-shadow: + inset 0 0 0 1px rgba(8, 145, 178, 0.34), + 0 10px 24px rgba(14, 165, 233, 0.14); +} + .chat-share-page__room-card-head { display: flex; align-items: center; @@ -265,6 +344,17 @@ line-height: 1.4; } +.chat-share-page__room-list-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 84px; + padding: 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.9); +} + .chat-share-page__create-room-form { display: grid; gap: 12px; @@ -1515,6 +1605,10 @@ } @media (max-width: 768px) { + .chat-share-page__room-list-panel { + gap: 10px; + } + .chat-share-page__program-app-shell--system-chat-room { padding: 0; } @@ -2258,12 +2352,38 @@ gap: 12px; } +.chat-share-page__previous-question-modal-dialog { + top: max(72px, calc(env(safe-area-inset-top, 0px) + 56px)); + padding-bottom: 16px; +} + +.chat-share-page__previous-question-modal-dialog .ant-modal-content { + max-height: min( + calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 96px), + 720px + ); + border-radius: 20px; + overflow: hidden; +} + +.chat-share-page__previous-question-modal-dialog .ant-modal-body { + overflow-y: auto; + overscroll-behavior: contain; +} + .chat-share-page__previous-question-modal-section { display: grid; gap: 8px; padding: 12px 0; } +.chat-share-page__previous-question-modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + .chat-share-page__previous-question-modal-section + .chat-share-page__previous-question-modal-section { border-top: 1px solid #e5eaf1; } diff --git a/src/app/main/pages/ChatSharePage.tsx b/src/app/main/pages/ChatSharePage.tsx index e9c383e..785320b 100644 --- a/src/app/main/pages/ChatSharePage.tsx +++ b/src/app/main/pages/ChatSharePage.tsx @@ -42,6 +42,7 @@ import { clearChatShareConversationRoom, completeChatShareManualBadge, createChatShareRoom, + deleteChatShareRoom, fetchChatShareRuntimeSnapshot, fetchChatShareSnapshot, getStoredChatShareAccessPin, @@ -97,6 +98,12 @@ const SHARE_TOKEN_USAGE_CLOCK_INTERVAL_MS = 1000; const SHARE_EXPIRY_CLOCK_INTERVAL_MS = 60 * 1000; const SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-share-immediate-send-pinned-by-token'; const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000; +const SHARE_ROOM_SWIPE_DELETE_LIMIT_PX = 108; +const SHARE_ROOM_SWIPE_DELETE_THRESHOLD_PX = 72; +const SHARE_ROOM_TOUCH_TAP_SLOP_PX = 14; +const SHARE_ROOM_TOUCH_SCROLL_CANCEL_PX = 18; +const SHARE_ROOM_SWIPE_ACTIVATION_PX = 18; +const SHARE_ROOM_SWIPE_START_EDGE_PX = 56; const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [ { value: 'always', label: '매번 묻기', minutes: 0 }, { value: '5', label: '5분 유지', minutes: 5 }, @@ -249,6 +256,14 @@ function parseAccessPinPromptTtlOptionValue(value: string) { return matchedOption?.minutes ?? null; } +function canDeleteShareRoom(room: ChatShareRoomSummary, rooms: ChatShareRoomSummary[]) { + if (room.isDefault) { + return false; + } + + return rooms.length > 1; +} + function readStoredShareImmediateSendPinnedByToken() { if (typeof window === 'undefined') { return {} as Record; @@ -1551,6 +1566,18 @@ function buildRequestAnswerText( return stripHiddenPreviewTags(String(request.responseText ?? '').replace(DIFF_CODE_BLOCK_PATTERN, '')).trim(); } +function resolveShareRequestFallbackAnswerText(request: ChatConversationRequest) { + if (request.status === 'queued') { + return '요청 대기 등록 하였습니다.'; + } + + if (request.status === 'accepted' || request.status === 'started') { + return '요청 처리 중 입니다.'; + } + + return request.statusMessage?.trim() || '아직 답변이 없습니다.'; +} + function replaceChatShareSnapshotRequest(snapshot: ChatShareSnapshot, nextRequest: ChatConversationRequest): ChatShareSnapshot { return { ...snapshot, @@ -1580,6 +1607,41 @@ function resolveShareConversationParentRequest( return parentRequestId ? requestById.get(parentRequestId) ?? null : null; } +function resolveShareRequestLineage( + request: ChatConversationRequest | null | undefined, + requestById: ReadonlyMap, +) { + const directParentRequest = resolveShareConversationParentRequest(request, requestById); + + if (!directParentRequest) { + return { + directParentRequest: null, + topParentRequest: null, + }; + } + + let currentRequest: ChatConversationRequest | null = directParentRequest; + let topParentRequest: ChatConversationRequest | null = directParentRequest; + const visitedRequestIds = new Set(); + + while (currentRequest) { + const currentRequestId = currentRequest.requestId.trim(); + + if (!currentRequestId || visitedRequestIds.has(currentRequestId)) { + break; + } + + visitedRequestIds.add(currentRequestId); + topParentRequest = currentRequest; + currentRequest = resolveShareConversationParentRequest(currentRequest, requestById); + } + + return { + directParentRequest, + topParentRequest, + }; +} + function buildSharePreviewItemsFromText(text: string, shareToken: string) { if (!shareToken) { return []; @@ -2936,7 +2998,11 @@ function ShareRequestCard({ () => buildSharePreviewItemsFromText(request.userText, shareToken), [request.userText, shareToken], ); - const resolvedAnswerText = answerText.trim() || request.statusMessage?.trim() || '아직 답변이 없습니다.'; + const { directParentRequest, topParentRequest } = useMemo( + () => resolveShareRequestLineage(request, requestById), + [request, requestById], + ); + const resolvedAnswerText = answerText.trim() || resolveShareRequestFallbackAnswerText(request); const shouldRenderQuestion = mode !== 'answer-only'; const shouldRenderFullAnswer = mode === 'full' || mode === 'answer-only'; const isRequestStillRunning = isRequestInFlight(request.status); @@ -2975,8 +3041,22 @@ function ShareRequestCard({ && !request.hasResponse && request.status === 'queued'; const retryCount = Math.max(0, Number(request.retryCount ?? 0) || 0); + const hasQuestionLineage = Boolean(directParentRequest || topParentRequest); const questionActions = ( <> + {hasQuestionLineage && onOpenPreviousQuestion ? ( + + {canDeleteRoom ? ( + + ) : null} + + ); })} + {filteredShareRooms.length === 0 ? ( +
+ 조건에 맞는 채팅방이 없습니다. +
+ ) : null} ) : null} @@ -7644,36 +8066,96 @@ export function ChatSharePage() { ) : null} { + setPendingDeleteRoomSessionId(''); + }} + onOk={async () => { + if (!pendingDeleteRoom) { + return; + } + + await handleDeleteShareRoom(pendingDeleteRoom); + }} + > + + {pendingDeleteRoom + ? `"${pendingDeleteRoom.title}" 채팅방과 이 방의 요청·메시지 기록이 삭제됩니다.` + : '선택한 공유 채팅방과 이 방의 요청·메시지 기록이 삭제됩니다.'} + + + { setPreviousQuestionModalRequestId(''); }} >
-
- 부모 요청 - - {previousQuestionModalRequest ? formatTimeLabel(previousQuestionModalRequest.createdAt) || '요청 시각 없음' : ''} - - - {previousQuestionModalText || '부모 요청 내용을 찾지 못했습니다.'} - - {previousQuestionModalPreviewItems.length > 0 ? ( -
- {previousQuestionModalPreviewItems.map((item) => ( - - ))} + {previousQuestionModalDirectParent ? ( +
+
+ 바로 상위 부모 + + {formatTimeLabel(previousQuestionModalDirectParent.createdAt) || '요청 시각 없음'} +
- ) : null} -
+ + {previousQuestionModalDirectParentText || '부모 질의 내용을 찾지 못했습니다.'} + + {previousQuestionModalDirectParentPreviewItems.length > 0 ? ( +
+ {previousQuestionModalDirectParentPreviewItems.map((item) => ( + + ))} +
+ ) : null} +
+ ) : null} + {previousQuestionModalTopParent ? ( +
+
+ 연결된 최상위 부모 + + {formatTimeLabel(previousQuestionModalTopParent.createdAt) || '요청 시각 없음'} + +
+ + {previousQuestionModalTopParentText || '최상위 부모 질의 내용을 찾지 못했습니다.'} + + {previousQuestionModalTopParentPreviewItems.length > 0 ? ( +
+ {previousQuestionModalTopParentPreviewItems.map((item) => ( + + ))} +
+ ) : null} +
+ ) : null} + {!previousQuestionModalDirectParent && !previousQuestionModalTopParent ? ( +
+ + 부모 질의 내용을 찾지 못했습니다. + +
+ ) : null}
} + onClick={handleReloadProgram} + /> + ) : null + } zIndex={SHARE_PROGRAM_MODAL_Z_INDEX} maskClosable={false} hideHeader={false} @@ -8413,23 +8907,28 @@ export function ChatSharePage() { > {programTarget ? ( embeddedPlayAppContent ? ( -
+
{embeddedPlayAppContent}
) : programTarget.appId && findReadyPlayAppEntryById(programTarget.appId) ? (