chore: test deploy snapshot

This commit is contained in:
2026-05-27 16:35:12 +09:00
parent e8a628ac34
commit 10805d242e
7 changed files with 1032 additions and 72 deletions

View File

@@ -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),

View File

@@ -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,

View File

@@ -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();

View File

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

View File

@@ -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)}`,

View File

@@ -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;
}

View File

@@ -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<string, boolean>;
@@ -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<string, ChatConversationRequest>,
) {
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<string>();
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 ? (
<Button
type="text"
size="small"
className="chat-share-page__message-action-button"
icon={<CommentOutlined />}
aria-label="부모 질의 보기"
title="부모 질의 보기"
onClick={() => {
onOpenPreviousQuestion(request.requestId);
}}
/>
) : null}
{canCancelDisconnectedRequest ? (
<Button
type="text"
@@ -3239,7 +3319,7 @@ export function ChatSharePage() {
const [pendingVerificationCompletionRequestIds, setPendingVerificationCompletionRequestIds] = useState<string[]>([]);
const [pendingRequestCancellationIds, setPendingRequestCancellationIds] = useState<string[]>([]);
const [pendingRequestRetryIds, setPendingRequestRetryIds] = useState<string[]>([]);
const [isShareRoomListVisible, setIsShareRoomListVisible] = useState(true);
const [isShareRoomListVisible, setIsShareRoomListVisible] = useState(false);
const [isRoomSwitching, setIsRoomSwitching] = useState(false);
const [replyReferenceRequestId, setReplyReferenceRequestId] = useState('');
const [previousQuestionModalRequestId, setPreviousQuestionModalRequestId] = useState('');
@@ -3283,6 +3363,12 @@ export function ChatSharePage() {
const [optimisticShareRooms, setOptimisticShareRooms] = useState<ChatShareRoomSummary[]>([]);
const [isRefreshingRoomNotificationStatus, setIsRefreshingRoomNotificationStatus] = useState(false);
const [isSendingRoomNotificationTest, setIsSendingRoomNotificationTest] = useState(false);
const [isDeletingRoom, setIsDeletingRoom] = useState(false);
const [pendingDeleteRoomSessionId, setPendingDeleteRoomSessionId] = useState('');
const [swipedRoomSessionId, setSwipedRoomSessionId] = useState('');
const [draggingRoomSessionId, setDraggingRoomSessionId] = useState('');
const [draggingRoomOffsetX, setDraggingRoomOffsetX] = useState(0);
const [shareRoomFilterKeyword, setShareRoomFilterKeyword] = useState('');
const [searchKeyword, setSearchKeyword] = useState('');
const [searchPanelMode, setSearchPanelMode] = useState<ShareSearchPanelMode>('all');
const [selectedAppEnvironment, setSelectedAppEnvironment] = useState<ShareAppEnvironment>(() =>
@@ -3291,6 +3377,7 @@ export function ChatSharePage() {
const [programTarget, setProgramTarget] = useState<ShareProgramTarget | null>(null);
const [programMinimizedTarget, setProgramMinimizedTarget] = useState<ShareProgramTarget | null>(null);
const [isProgramMinimized, setIsProgramMinimized] = useState(false);
const [programReloadKey, setProgramReloadKey] = useState(0);
const programMinimizedCardRef = useRef<HTMLDivElement | null>(null);
const programMinimizedDragStateRef = useRef<{
pointerId: number;
@@ -3299,6 +3386,14 @@ export function ChatSharePage() {
captureTarget: HTMLDivElement;
} | null>(null);
const programMinimizedMovedRef = useRef(false);
const roomSwipeStartXRef = useRef<number | null>(null);
const roomSwipeStartYRef = useRef<number | null>(null);
const roomSwipeLockedSessionIdRef = useRef('');
const roomSwipeStartEligibleRef = useRef(false);
const roomSwipeMovedRef = useRef(false);
const roomTouchScrollDetectedRef = useRef(false);
const suppressRoomClickRef = useRef(false);
const skipNextRoomClickSessionIdRef = useRef('');
const programMinimizedPositionRef = useRef(getDefaultProgramMinimizedPosition());
const [programMinimizedPosition, setProgramMinimizedPosition] = useState(() => programMinimizedPositionRef.current);
const composerRef = useRef<TextAreaRef | null>(null);
@@ -3348,6 +3443,30 @@ export function ChatSharePage() {
() => shareRooms.find((item) => item.sessionId === selectedShareRoomSessionId) ?? null,
[selectedShareRoomSessionId, shareRooms],
);
const filteredShareRooms = useMemo(() => {
const keyword = shareRoomFilterKeyword.trim().toLowerCase();
if (!keyword) {
return shareRooms;
}
return shareRooms.filter((room) => {
const searchIndex = [
room.title,
room.contextLabel,
room.requestBadgeLabel,
room.sessionId,
]
.map((value) => value?.trim().toLowerCase() ?? '')
.filter(Boolean)
.join(' ');
return searchIndex.includes(keyword);
});
}, [shareRoomFilterKeyword, shareRooms]);
const pendingDeleteRoom = useMemo(
() => shareRooms.find((item) => item.sessionId === pendingDeleteRoomSessionId) ?? null,
[pendingDeleteRoomSessionId, shareRooms],
);
useEffect(() => {
if (optimisticShareRooms.length === 0 || !snapshot?.rooms?.length) {
return;
@@ -3831,6 +3950,111 @@ export function ChatSharePage() {
setCreatingRoomSeedMessage('이 방에서 이어갈 작업 내용을 남겨 주세요. 필요한 파일이나 참고 문맥이 있으면 함께 적어 주세요.');
setIsCreateRoomOpen(true);
}, [currentSharedChatTypeId, enabledChatTypes]);
const resetRoomSwipeState = useCallback(() => {
roomSwipeStartXRef.current = null;
roomSwipeStartYRef.current = null;
roomSwipeLockedSessionIdRef.current = '';
roomSwipeStartEligibleRef.current = false;
roomSwipeMovedRef.current = false;
roomTouchScrollDetectedRef.current = false;
setDraggingRoomSessionId('');
setDraggingRoomOffsetX(0);
}, []);
const handleRoomSwipeStart = useCallback((
sessionId: string,
clientX: number,
clientY: number,
swipeStartAllowed: boolean,
) => {
const targetRoom = shareRooms.find((room) => room.sessionId === sessionId);
if (isDeletingRoom || !targetRoom || !canDeleteShareRoom(targetRoom, shareRooms)) {
return;
}
roomSwipeStartXRef.current = clientX;
roomSwipeStartYRef.current = clientY;
roomSwipeLockedSessionIdRef.current = sessionId;
roomSwipeStartEligibleRef.current = swipeStartAllowed;
roomSwipeMovedRef.current = false;
roomTouchScrollDetectedRef.current = false;
setDraggingRoomSessionId(sessionId);
setDraggingRoomOffsetX(0);
setSwipedRoomSessionId((current) => (current === sessionId ? sessionId : ''));
}, [isDeletingRoom, shareRooms]);
const handleRoomSwipeMove = useCallback((sessionId: string, clientX: number, clientY: number) => {
if (
roomSwipeLockedSessionIdRef.current !== sessionId
|| roomSwipeStartXRef.current == null
|| roomSwipeStartYRef.current == null
) {
return;
}
const deltaX = clientX - roomSwipeStartXRef.current;
const deltaY = clientY - roomSwipeStartYRef.current;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (absDeltaY <= SHARE_ROOM_TOUCH_TAP_SLOP_PX && absDeltaX <= SHARE_ROOM_TOUCH_TAP_SLOP_PX) {
return;
}
if (absDeltaY >= SHARE_ROOM_TOUCH_SCROLL_CANCEL_PX && absDeltaY > absDeltaX) {
roomTouchScrollDetectedRef.current = true;
setSwipedRoomSessionId('');
setDraggingRoomSessionId('');
setDraggingRoomOffsetX(0);
return;
}
if (absDeltaX < SHARE_ROOM_SWIPE_ACTIVATION_PX || absDeltaX <= absDeltaY) {
return;
}
if (!roomSwipeStartEligibleRef.current) {
setDraggingRoomSessionId(sessionId);
setDraggingRoomOffsetX(0);
return;
}
if (deltaX >= 0) {
setDraggingRoomSessionId(sessionId);
setDraggingRoomOffsetX(0);
return;
}
const nextOffset = Math.max(deltaX, -SHARE_ROOM_SWIPE_DELETE_LIMIT_PX);
if (Math.abs(nextOffset) >= SHARE_ROOM_SWIPE_ACTIVATION_PX) {
roomSwipeMovedRef.current = true;
suppressRoomClickRef.current = true;
}
setDraggingRoomSessionId(sessionId);
setDraggingRoomOffsetX(nextOffset);
}, []);
const handleRoomSwipeEnd = useCallback((sessionId: string) => {
if (roomSwipeLockedSessionIdRef.current !== sessionId) {
return true;
}
if (roomTouchScrollDetectedRef.current) {
resetRoomSwipeState();
setSwipedRoomSessionId('');
window.setTimeout(() => {
suppressRoomClickRef.current = false;
}, 0);
return false;
}
const shouldRevealDelete = draggingRoomOffsetX <= -SHARE_ROOM_SWIPE_DELETE_THRESHOLD_PX;
const wasSwipeGesture = roomSwipeMovedRef.current;
resetRoomSwipeState();
setSwipedRoomSessionId(shouldRevealDelete ? sessionId : '');
window.setTimeout(() => {
suppressRoomClickRef.current = false;
}, 0);
return !wasSwipeGesture && !shouldRevealDelete;
}, [draggingRoomOffsetX, resetRoomSwipeState]);
const refreshShareRuntime = useCallback(async (options?: { silent?: boolean }) => {
if (!normalizedToken || !selectedShareRoomSessionId) {
setShareRuntimeSnapshot(null);
@@ -3957,6 +4181,74 @@ export function ChatSharePage() {
snapshotRefreshPromiseRef.current = refreshTask;
return refreshTask;
}, [normalizedToken]);
const handleDeleteShareRoom = useCallback(async (room: ChatShareRoomSummary) => {
if (!normalizedToken || !room.sessionId || isDeletingRoom) {
return;
}
if (!canDeleteShareRoom(room, shareRooms)) {
message.warning(room.isDefault ? '기본 채팅방은 삭제할 수 없습니다.' : '마지막 채팅방은 삭제할 수 없습니다.');
return;
}
setIsDeletingRoom(true);
try {
const result = await deleteChatShareRoom(normalizedToken, room.sessionId);
const fallbackSessionId =
result.nextRoomSessionId
|| shareRooms.find((item) => item.sessionId !== room.sessionId)?.sessionId
|| '';
setOptimisticShareRooms((current) => current.filter((item) => item.sessionId !== room.sessionId));
setSnapshot((current) => {
if (!current) {
return current;
}
const nextRooms = current.rooms.filter((item) => item.sessionId !== room.sessionId);
const nextActiveSessionId =
current.activeSessionId === room.sessionId
? (result.nextRoomSessionId || nextRooms.find((item) => item.isDefault)?.sessionId || nextRooms[0]?.sessionId || '')
: current.activeSessionId;
return {
...current,
rooms: nextRooms,
activeSessionId: nextActiveSessionId,
};
});
setPendingDeleteRoomSessionId('');
setSwipedRoomSessionId('');
requestedRoomSessionIdRef.current =
requestedRoomSessionIdRef.current === room.sessionId ? fallbackSessionId : requestedRoomSessionIdRef.current;
setRequestedRoomSessionId((current) => (current === room.sessionId ? fallbackSessionId : current));
if (selectedShareRoomSessionId === room.sessionId) {
setDraftText('');
setComposerAttachments([]);
setReplyReferenceRequestId('');
setLatestRequestId('');
setExpandMode('pending');
}
await refreshSnapshot({ silent: true });
message.success(`"${room.title}" 채팅방을 삭제했습니다.`);
} catch (error) {
message.error(error instanceof Error ? error.message : '공유 채팅방 삭제 중 오류가 발생했습니다.');
} finally {
resetRoomSwipeState();
setIsDeletingRoom(false);
}
}, [
isDeletingRoom,
message,
normalizedToken,
refreshSnapshot,
resetRoomSwipeState,
selectedShareRoomSessionId,
shareRooms,
]);
const handleCancelShareRuntimeRequest = useCallback((item: ChatRuntimeJobItem) => {
const actionLabel = item.status === 'queued' ? '대기 요청' : '실행 중 요청';
@@ -4448,6 +4740,10 @@ export function ChatSharePage() {
window.location.reload();
}, []);
const handleReloadProgram = useCallback(() => {
setProgramReloadKey((current) => current + 1);
}, []);
useEffect(() => {
setAccessPinInput('');
setAccessPinSubmitError('');
@@ -4743,6 +5039,16 @@ export function ChatSharePage() {
setRequestedRoomSessionId('');
}, [requestedRoomSessionId, shareRooms]);
useEffect(() => {
if (isDeletingRoom) {
return;
}
if (!shareRooms.some((room) => room.sessionId === swipedRoomSessionId)) {
setSwipedRoomSessionId('');
resetRoomSwipeState();
}
}, [isDeletingRoom, resetRoomSwipeState, shareRooms, swipedRoomSessionId]);
useEffect(() => {
if (typeof window === 'undefined') {
@@ -5228,13 +5534,30 @@ export function ChatSharePage() {
}
};
const handleSelectShareRoom = useCallback((sessionId: string) => {
const handleSelectShareRoom = useCallback((sessionId: string, options?: { bypassSuppression?: boolean }) => {
const normalizedSessionId = sessionId.trim();
const shouldBypassSuppression = options?.bypassSuppression === true;
if (!shouldBypassSuppression && skipNextRoomClickSessionIdRef.current === normalizedSessionId) {
skipNextRoomClickSessionIdRef.current = '';
return;
}
if (!shouldBypassSuppression && (suppressRoomClickRef.current || roomSwipeMovedRef.current)) {
suppressRoomClickRef.current = false;
return;
}
if (!normalizedSessionId) {
return;
}
if (swipedRoomSessionId === normalizedSessionId || draggingRoomSessionId === normalizedSessionId) {
setSwipedRoomSessionId('');
resetRoomSwipeState();
return;
}
if (normalizedSessionId === selectedShareRoomSessionId) {
setIsShareRoomListVisible(false);
return;
@@ -5244,7 +5567,7 @@ export function ChatSharePage() {
setIsRoomSwitching(true);
setRequestedRoomSessionId(normalizedSessionId);
setIsShareRoomListVisible(false);
}, [selectedShareRoomSessionId]);
}, [draggingRoomSessionId, resetRoomSwipeState, selectedShareRoomSessionId, swipedRoomSessionId]);
const handleCreateShareRoom = useCallback(async () => {
if (!normalizedToken || isCreatingRoom) {
@@ -6013,17 +6336,35 @@ export function ChatSharePage() {
return summarizeShareReplyReferenceText(answerText || replyReferenceRequest.userText || '선택한 답변');
}, [replyReferenceRequest, requestAnswerTextById]);
const previousQuestionModalRequest = useMemo(
const previousQuestionModalTargetRequest = useMemo(
() => (previousQuestionModalRequestId.trim() ? requestById.get(previousQuestionModalRequestId.trim()) ?? null : null),
[previousQuestionModalRequestId, requestById],
);
const previousQuestionModalText = useMemo(
() => resolveShareRequestQuestionText(previousQuestionModalRequest),
[previousQuestionModalRequest],
const previousQuestionModalLineage = useMemo(
() => resolveShareRequestLineage(previousQuestionModalTargetRequest, requestById),
[previousQuestionModalTargetRequest, requestById],
);
const previousQuestionModalPreviewItems = useMemo(
() => buildSharePreviewItemsFromText(previousQuestionModalRequest?.userText ?? '', normalizedToken),
[normalizedToken, previousQuestionModalRequest?.userText],
const previousQuestionModalDirectParent = previousQuestionModalLineage.directParentRequest;
const previousQuestionModalTopParent =
previousQuestionModalLineage.topParentRequest
&& previousQuestionModalLineage.topParentRequest.requestId.trim() !== previousQuestionModalLineage.directParentRequest?.requestId.trim()
? previousQuestionModalLineage.topParentRequest
: null;
const previousQuestionModalDirectParentText = useMemo(
() => resolveShareRequestQuestionText(previousQuestionModalDirectParent),
[previousQuestionModalDirectParent],
);
const previousQuestionModalTopParentText = useMemo(
() => resolveShareRequestQuestionText(previousQuestionModalTopParent),
[previousQuestionModalTopParent],
);
const previousQuestionModalDirectParentPreviewItems = useMemo(
() => buildSharePreviewItemsFromText(previousQuestionModalDirectParent?.userText ?? '', normalizedToken),
[normalizedToken, previousQuestionModalDirectParent?.userText],
);
const previousQuestionModalTopParentPreviewItems = useMemo(
() => buildSharePreviewItemsFromText(previousQuestionModalTopParent?.userText ?? '', normalizedToken),
[normalizedToken, previousQuestionModalTopParent?.userText],
);
const activityLogByRequestId = useMemo(
() => new Map((snapshot?.activityLogs ?? []).map((item) => [item.requestId.trim(), item])),
@@ -6164,6 +6505,7 @@ export function ChatSharePage() {
}
recordShareAppLaunch(target.appId);
setProgramReloadKey(0);
setProgramTarget(target);
setProgramMinimizedTarget(target);
setIsProgramMinimized(false);
@@ -7206,6 +7548,17 @@ export function ChatSharePage() {
<div className={contentLayoutClassName}>
{canToggleShareRoomList && isShareRoomListVisible ? (
<section className="chat-share-page__panel chat-share-page__room-list-panel">
<Input
allowClear
value={shareRoomFilterKeyword}
onChange={(event) => {
setShareRoomFilterKeyword(event.target.value);
}}
className="chat-share-page__room-filter-input"
placeholder="채팅방 필터"
prefix={<SearchOutlined />}
aria-label="공유채팅 채팅방 필터"
/>
<div className="chat-share-page__section-head chat-share-page__section-head--compact">
<div className="chat-share-page__section-copy">
<Title level={5}></Title>
@@ -7220,28 +7573,97 @@ export function ChatSharePage() {
) : null}
</div>
<div className="chat-share-page__room-list">
{shareRooms.map((room) => {
{filteredShareRooms.map((room) => {
const isActive = room.sessionId === selectedShareRoomSessionId;
const canDeleteRoom = canDeleteShareRoom(room, shareRooms);
const isDeletingTarget = isDeletingRoom && pendingDeleteRoomSessionId === room.sessionId;
return (
<button
<div
key={room.sessionId}
type="button"
className={`chat-share-page__room-card${isActive ? ' chat-share-page__room-card--active' : ''}`}
onClick={() => {
handleSelectShareRoom(room.sessionId);
}}
className={`chat-share-page__room-swipe ${
swipedRoomSessionId === room.sessionId ? 'is-swiped' : ''
} ${draggingRoomSessionId === room.sessionId ? 'is-dragging' : ''} ${
canDeleteRoom ? '' : 'is-delete-locked'
}`}
>
<span className="chat-share-page__room-card-head">
<span className="chat-share-page__room-card-title">{room.title}</span>
{room.isDefault ? <Tag color="blue"></Tag> : null}
</span>
<span className="chat-share-page__room-card-meta">
{room.contextLabel?.trim() || room.requestBadgeLabel?.trim() || '공유 채팅방'}
</span>
</button>
{canDeleteRoom ? (
<button
type="button"
className="chat-share-page__room-delete-action"
aria-label={`${room.title} 채팅방 삭제`}
title={`${room.title} 채팅방 삭제`}
disabled={isDeletingTarget}
onClick={() => {
setPendingDeleteRoomSessionId(room.sessionId);
}}
>
<DeleteOutlined />
</button>
) : null}
<button
type="button"
className={`chat-share-page__room-card${isActive ? ' chat-share-page__room-card--active' : ''}${
room.isDefault ? ' chat-share-page__room-card--default' : ''
}`}
style={{
transform:
draggingRoomSessionId === room.sessionId
? `translate3d(${draggingRoomOffsetX}px, 0, 0)`
: canDeleteRoom && swipedRoomSessionId === room.sessionId
? `translate3d(-${SHARE_ROOM_SWIPE_DELETE_THRESHOLD_PX}px, 0, 0)`
: undefined,
}}
onTouchStart={(event) => {
const touch = event.changedTouches[0];
const rect = event.currentTarget.getBoundingClientRect();
handleRoomSwipeStart(
room.sessionId,
touch?.clientX ?? 0,
touch?.clientY ?? 0,
(touch?.clientX ?? 0) >= rect.right - SHARE_ROOM_SWIPE_START_EDGE_PX,
);
}}
onTouchMove={(event) => {
handleRoomSwipeMove(
room.sessionId,
event.changedTouches[0]?.clientX ?? 0,
event.changedTouches[0]?.clientY ?? 0,
);
}}
onTouchEnd={() => {
const shouldSelect = handleRoomSwipeEnd(room.sessionId);
if (shouldSelect) {
skipNextRoomClickSessionIdRef.current = room.sessionId;
handleSelectShareRoom(room.sessionId, { bypassSuppression: true });
}
}}
onTouchCancel={() => {
handleRoomSwipeEnd(room.sessionId);
}}
onClick={() => {
handleSelectShareRoom(room.sessionId);
}}
>
<span className="chat-share-page__room-card-head">
<span className="chat-share-page__room-card-title">{room.title}</span>
{room.isDefault ? <Tag color="cyan"> </Tag> : null}
</span>
<span className="chat-share-page__room-card-meta">
{room.isDefault
? (room.contextLabel?.trim() || room.requestBadgeLabel?.trim() || '삭제할 수 없는 기본 채팅방')
: (room.contextLabel?.trim() || room.requestBadgeLabel?.trim() || '공유 채팅방')}
</span>
</button>
</div>
);
})}
{filteredShareRooms.length === 0 ? (
<div className="chat-share-page__room-list-empty">
<Text type="secondary"> .</Text>
</div>
) : null}
</div>
</section>
) : null}
@@ -7644,36 +8066,96 @@ export function ChatSharePage() {
</div>
) : null}
<Modal
open={Boolean(previousQuestionModalRequest)}
title="부모 요청"
footer={null}
open={Boolean(pendingDeleteRoom)}
title="공유 채팅방을 삭제할까요?"
okText="삭제"
cancelText="취소"
okButtonProps={{ danger: true }}
centered
onCancel={() => {
setPendingDeleteRoomSessionId('');
}}
onOk={async () => {
if (!pendingDeleteRoom) {
return;
}
await handleDeleteShareRoom(pendingDeleteRoom);
}}
>
<Text>
{pendingDeleteRoom
? `"${pendingDeleteRoom.title}" 채팅방과 이 방의 요청·메시지 기록이 삭제됩니다.`
: '선택한 공유 채팅방과 이 방의 요청·메시지 기록이 삭제됩니다.'}
</Text>
</Modal>
<Modal
open={Boolean(previousQuestionModalDirectParent || previousQuestionModalTopParent)}
title="부모 질의"
footer={null}
className="chat-share-page__previous-question-modal-dialog"
onCancel={() => {
setPreviousQuestionModalRequestId('');
}}
>
<div className="chat-share-page__previous-question-modal">
<div className="chat-share-page__previous-question-modal-section">
<Text strong> </Text>
<Text type="secondary">
{previousQuestionModalRequest ? formatTimeLabel(previousQuestionModalRequest.createdAt) || '요청 시각 없음' : ''}
</Text>
<Paragraph className="chat-share-page__previous-question-modal-text">
{previousQuestionModalText || '부모 요청 내용을 찾지 못했습니다.'}
</Paragraph>
{previousQuestionModalPreviewItems.length > 0 ? (
<div className="chat-share-page__resource-list">
{previousQuestionModalPreviewItems.map((item) => (
<ShareResourcePreviewCard
key={`previous-question-${item.id}`}
item={item}
shareToken={normalizedToken}
onOpenProgram={openProgramTarget}
/>
))}
{previousQuestionModalDirectParent ? (
<div className="chat-share-page__previous-question-modal-section">
<div className="chat-share-page__previous-question-modal-head">
<Text strong> </Text>
<Text type="secondary">
{formatTimeLabel(previousQuestionModalDirectParent.createdAt) || '요청 시각 없음'}
</Text>
</div>
) : null}
</div>
<Paragraph className="chat-share-page__previous-question-modal-text">
{previousQuestionModalDirectParentText || '부모 질의 내용을 찾지 못했습니다.'}
</Paragraph>
{previousQuestionModalDirectParentPreviewItems.length > 0 ? (
<div className="chat-share-page__resource-list">
{previousQuestionModalDirectParentPreviewItems.map((item) => (
<ShareResourcePreviewCard
key={`previous-question-direct-${item.id}`}
item={item}
shareToken={normalizedToken}
onOpenProgram={openProgramTarget}
/>
))}
</div>
) : null}
</div>
) : null}
{previousQuestionModalTopParent ? (
<div className="chat-share-page__previous-question-modal-section">
<div className="chat-share-page__previous-question-modal-head">
<Text strong> </Text>
<Text type="secondary">
{formatTimeLabel(previousQuestionModalTopParent.createdAt) || '요청 시각 없음'}
</Text>
</div>
<Paragraph className="chat-share-page__previous-question-modal-text">
{previousQuestionModalTopParentText || '최상위 부모 질의 내용을 찾지 못했습니다.'}
</Paragraph>
{previousQuestionModalTopParentPreviewItems.length > 0 ? (
<div className="chat-share-page__resource-list">
{previousQuestionModalTopParentPreviewItems.map((item) => (
<ShareResourcePreviewCard
key={`previous-question-top-${item.id}`}
item={item}
shareToken={normalizedToken}
onOpenProgram={openProgramTarget}
/>
))}
</div>
) : null}
</div>
) : null}
{!previousQuestionModalDirectParent && !previousQuestionModalTopParent ? (
<div className="chat-share-page__previous-question-modal-section">
<Paragraph className="chat-share-page__previous-question-modal-text">
.
</Paragraph>
</div>
) : null}
</div>
</Modal>
<Modal
@@ -8400,6 +8882,18 @@ export function ChatSharePage() {
open={Boolean(programTarget) && !isProgramMinimized}
title={programTarget?.label ?? '공유 프로그램'}
meta={programTarget?.meta ?? '공유 토큰 실행'}
actions={
programTarget ? (
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="앱 새로고침"
title="앱 새로고침"
icon={<ReloadOutlined />}
onClick={handleReloadProgram}
/>
) : null
}
zIndex={SHARE_PROGRAM_MODAL_Z_INDEX}
maskClosable={false}
hideHeader={false}
@@ -8413,23 +8907,28 @@ export function ChatSharePage() {
>
{programTarget ? (
embeddedPlayAppContent ? (
<div className="chat-share-page__program-app-shell">
<div key={`${programTarget.key}:${programReloadKey}`} className="chat-share-page__program-app-shell">
{embeddedPlayAppContent}
</div>
) : programTarget.appId && findReadyPlayAppEntryById(programTarget.appId) ? (
<iframe
key={`${programTarget.key}:${programReloadKey}`}
title={programTarget.label}
src={programTarget.url}
className="app-chat-panel__preview-frame"
/>
) : programTarget.appId === SHARE_CURRENT_CHAT_APP_ID ? (
<iframe
key={`${programTarget.key}:${programReloadKey}`}
title={programTarget.label}
src={programTarget.url}
className="app-chat-panel__preview-frame"
/>
) : programTarget.appId === 'text-memo-widget' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<div
key={`${programTarget.key}:${programReloadKey}`}
className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface"
>
<Suspense
fallback={(
<div className="chat-share-page__program-app-loading" role="status" aria-live="polite">
@@ -8441,7 +8940,10 @@ export function ChatSharePage() {
</Suspense>
</div>
) : programTarget.appId === 'token-setting' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<div
key={`${programTarget.key}:${programReloadKey}`}
className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface"
>
<TokenSettingManagementPage
sharedPreviewTokenSetting={shareTokenSetting}
sharedAccess={
@@ -8455,7 +8957,10 @@ export function ChatSharePage() {
/>
</div>
) : programTarget.appId === 'shared-resource' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<div
key={`${programTarget.key}:${programReloadKey}`}
className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface"
>
<SharedResourceManagementPage
disableInstallMetadata
sharedPreview={{
@@ -8475,26 +8980,34 @@ export function ChatSharePage() {
/>
</div>
) : programTarget.appId === 'app-settings' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<div
key={`${programTarget.key}:${programReloadKey}`}
className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface"
>
<SharedAppSettingsPage shareToken={normalizedToken} />
</div>
) : programTarget.appId === 'server-command' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<div
key={`${programTarget.key}:${programReloadKey}`}
className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface"
>
<ServerCommandPage sharedAccess={sharedServerCommandAccess} />
</div>
) : (
<ChatPreviewBody
target={{
label: programTarget.label,
url: programTarget.url,
kind: programTarget.kind,
}}
previewText=""
isPreviewLoading={false}
previewError=""
renderHtmlAsFrame
fullscreen
/>
<div key={`${programTarget.key}:${programReloadKey}`} className="chat-share-page__program-app-shell">
<ChatPreviewBody
target={{
label: programTarget.label,
url: programTarget.url,
kind: programTarget.kind,
}}
previewText=""
isPreviewLoading={false}
previewError=""
renderHtmlAsFrame
fullscreen
/>
</div>
)
) : null}
</FullscreenPreviewModal>