chore: test deploy snapshot
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user