chore: test deploy snapshot
This commit is contained in:
@@ -138,6 +138,7 @@ import type {
|
||||
import { consumeCodexLiveDraft } from './codexLiveDraftBridge';
|
||||
import { useChatActionContextSnapshot } from './chatActionContextStore';
|
||||
import { getOrCreateClientId } from './clientIdentity';
|
||||
import { getSavedNotificationDeviceId } from './notificationIdentity';
|
||||
import { requestChatWindowAction } from './chatWindowActions';
|
||||
import {
|
||||
buildIsolatedChatRoomContextSupplement,
|
||||
@@ -1952,6 +1953,28 @@ function hasConversationAttentionResponseTarget(message: ChatMessage) {
|
||||
return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalizedText);
|
||||
}
|
||||
|
||||
function normalizePromptContextRef(value: ChatPromptContextRef | null | undefined): ChatPromptContextRef | null {
|
||||
if (!value || value.key !== 'prompt_parent_question') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const promptTitle = String(value.promptTitle ?? '').trim();
|
||||
|
||||
if (!promptTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const promptDescription = String(value.promptDescription ?? '').trim();
|
||||
const parentQuestionText = String(value.parentQuestionText ?? '').trim();
|
||||
|
||||
return {
|
||||
key: 'prompt_parent_question',
|
||||
promptTitle,
|
||||
promptDescription: promptDescription || null,
|
||||
parentQuestionText: parentQuestionText || null,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeConversationRequestPreservingContent(
|
||||
previousItem: ChatConversationRequest | null | undefined,
|
||||
nextItem: ChatConversationRequest,
|
||||
@@ -1970,15 +1993,22 @@ function mergeConversationRequestPreservingContent(
|
||||
chatTypeLabel: nextItem.chatTypeLabel?.trim() || previousItem.chatTypeLabel?.trim() || '',
|
||||
requestOrigin: nextItem.requestOrigin ?? previousItem.requestOrigin ?? null,
|
||||
parentRequestId: nextItem.parentRequestId?.trim() || previousItem.parentRequestId?.trim() || null,
|
||||
promptContextRef: normalizePromptContextRef(nextItem.promptContextRef) ?? normalizePromptContextRef(previousItem.promptContextRef),
|
||||
statusMessage: nextStatusMessage,
|
||||
retryCount: Math.max(nextItem.retryCount ?? 0, previousItem.retryCount ?? 0),
|
||||
userMessageId: nextItem.userMessageId ?? previousItem.userMessageId,
|
||||
userText: nextUserText,
|
||||
responseMessageId: nextItem.responseMessageId ?? previousItem.responseMessageId,
|
||||
responseText: nextResponseText,
|
||||
hasResponse: nextItem.hasResponse || previousItem.hasResponse || nextResponseText.length > 0,
|
||||
manualPromptCompletedAt: nextItem.manualPromptCompletedAt ?? previousItem.manualPromptCompletedAt ?? null,
|
||||
manualPromptCompletedAt:
|
||||
nextItem.manualPromptCompletedAt !== undefined
|
||||
? nextItem.manualPromptCompletedAt
|
||||
: previousItem.manualPromptCompletedAt ?? null,
|
||||
manualVerificationCompletedAt:
|
||||
nextItem.manualVerificationCompletedAt ?? previousItem.manualVerificationCompletedAt ?? null,
|
||||
nextItem.manualVerificationCompletedAt !== undefined
|
||||
? nextItem.manualVerificationCompletedAt
|
||||
: previousItem.manualVerificationCompletedAt ?? null,
|
||||
answeredAt: nextItem.answeredAt ?? previousItem.answeredAt,
|
||||
terminalAt: nextItem.terminalAt ?? previousItem.terminalAt,
|
||||
};
|
||||
@@ -2554,7 +2584,7 @@ function mergeConversationSummaryPreservingChatType(
|
||||
contextDescription: nextItem.contextDescription?.trim() || null,
|
||||
isPendingWork: nextItem.isPendingWork ?? previousItem.isPendingWork ?? false,
|
||||
pendingWorkReason: nextItem.pendingWorkReason ?? previousItem.pendingWorkReason ?? null,
|
||||
hasPendingAttention: nextItem.hasPendingAttention === true || previousItem.hasPendingAttention === true,
|
||||
hasPendingAttention: nextItem.hasPendingAttention === true,
|
||||
lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '',
|
||||
lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||
lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
|
||||
@@ -4429,6 +4459,7 @@ export function MainChatPanel({
|
||||
Object.entries(notificationData).flatMap(([key, value]) => (value ? [[key, String(value)]] : [])),
|
||||
);
|
||||
const currentClientId = getOrCreateClientId().trim();
|
||||
const notificationDeviceId = getSavedNotificationDeviceId().trim();
|
||||
|
||||
if (!requestOwnerClientId || !currentClientId || requestOwnerClientId !== currentClientId) {
|
||||
return;
|
||||
@@ -4451,7 +4482,8 @@ export function MainChatPanel({
|
||||
body: notificationBody,
|
||||
threadId: `chat:${sessionId}`,
|
||||
data: serializedNotificationData,
|
||||
targetClientIds: currentClientId ? [currentClientId] : undefined,
|
||||
targetDeviceIds: notificationDeviceId ? [notificationDeviceId] : undefined,
|
||||
targetAppOrigins: typeof window !== 'undefined' ? [window.location.origin] : undefined,
|
||||
}),
|
||||
]).then(async ([storedResult, pushResult]) => {
|
||||
if (pushResult.status === 'rejected') {
|
||||
@@ -5150,7 +5182,6 @@ export function MainChatPanel({
|
||||
activeView,
|
||||
isMobileViewport,
|
||||
previewItems,
|
||||
selectedChatTypeId,
|
||||
composerRef,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
@@ -7361,6 +7392,129 @@ export function MainChatPanel({
|
||||
[activeSessionId, handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage],
|
||||
);
|
||||
|
||||
const resolvePromptContextRefForRequest = useCallback(
|
||||
(requestId: string): ChatPromptContextRef | null => {
|
||||
const normalizedRequestId = requestId.trim();
|
||||
|
||||
if (!normalizedRequestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestMap = new Map(
|
||||
requestItemsRef.current
|
||||
.filter((item) => item.sessionId === activeSessionId)
|
||||
.map((item) => [item.requestId.trim(), item] as const),
|
||||
);
|
||||
const visitedRequestIds = new Set<string>();
|
||||
let currentRequestId = normalizedRequestId;
|
||||
|
||||
while (currentRequestId && !visitedRequestIds.has(currentRequestId)) {
|
||||
visitedRequestIds.add(currentRequestId);
|
||||
const currentRequest = requestMap.get(currentRequestId);
|
||||
|
||||
if (!currentRequest) {
|
||||
break;
|
||||
}
|
||||
|
||||
const promptContextRef = normalizePromptContextRef(currentRequest.promptContextRef);
|
||||
|
||||
if (promptContextRef) {
|
||||
return promptContextRef;
|
||||
}
|
||||
|
||||
currentRequestId = currentRequest.parentRequestId?.trim() || '';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[activeSessionId],
|
||||
);
|
||||
|
||||
const sendReplyToRequest = useCallback(
|
||||
({
|
||||
text,
|
||||
parentRequestId,
|
||||
mode,
|
||||
origin = 'composer',
|
||||
}: {
|
||||
text: string;
|
||||
parentRequestId: string;
|
||||
mode: 'queue' | 'direct';
|
||||
origin?: 'composer' | 'prompt';
|
||||
}): SendMessageResult => {
|
||||
const trimmed = text.trim();
|
||||
const normalizedParentRequestId = parentRequestId.trim();
|
||||
const targetSessionId = activeSessionId.trim();
|
||||
|
||||
if (!trimmed || !normalizedParentRequestId) {
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
if (!effectiveChatType) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 답글 요청을 전송하지 못했습니다.'),
|
||||
]);
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
if (!targetSessionId) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('활성 대화방이 없어서 답글 요청을 전송하지 못했습니다.'),
|
||||
]);
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
void executeSendMessageSafely({
|
||||
sessionId: targetSessionId,
|
||||
mode,
|
||||
text: trimmed,
|
||||
origin,
|
||||
parentRequestId: normalizedParentRequestId,
|
||||
promptContextRef: resolvePromptContextRefForRequest(normalizedParentRequestId),
|
||||
codexModel: effectiveCodexModel,
|
||||
chatTypeId: effectiveChatType.id,
|
||||
chatTypeLabel: effectiveChatType.name,
|
||||
chatTypeDescription: effectiveChatTypeDescription,
|
||||
chatTypeBaseDescription: effectiveChatType.description,
|
||||
defaultContextIds: effectiveDefaultContextIds,
|
||||
defaultContexts: effectiveDefaultContexts,
|
||||
customContextTitle: effectiveRoomCustomContextTitle || null,
|
||||
customContextContent: effectiveRoomCustomContextContent || null,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
|
||||
return 'sent';
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createLocalMessage,
|
||||
effectiveCodexModel,
|
||||
effectiveDefaultContextIds,
|
||||
effectiveDefaultContexts,
|
||||
effectiveChatType,
|
||||
effectiveChatTypeDescription,
|
||||
effectiveRoomCustomContextContent,
|
||||
effectiveRoomCustomContextTitle,
|
||||
executeSendMessageSafely,
|
||||
resolvePromptContextRefForRequest,
|
||||
setMessages,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSendReplyToResponse = useCallback(
|
||||
({ draftText, parentRequestId }: { draftText?: string; parentRequestId: string }): SendMessageResult =>
|
||||
sendReplyToRequest({
|
||||
text: buildOutgoingMessageText(draftText ?? draftRef.current, composerAttachments),
|
||||
parentRequestId,
|
||||
mode: isImmediateSendPinned ? 'direct' : 'queue',
|
||||
origin: 'composer',
|
||||
}),
|
||||
[buildOutgoingMessageText, composerAttachments, draftRef, isImmediateSendPinned, sendReplyToRequest],
|
||||
);
|
||||
|
||||
const handlePromoteQueuedRequest = useCallback(
|
||||
async (requestId: string, text: string) => {
|
||||
const normalizedRequestId = requestId.trim();
|
||||
@@ -7370,6 +7524,11 @@ export function MainChatPanel({
|
||||
return;
|
||||
}
|
||||
|
||||
const existingRequest =
|
||||
requestItemsRef.current.find(
|
||||
(item) => item.sessionId === activeSessionId && item.requestId === normalizedRequestId,
|
||||
) ?? null;
|
||||
|
||||
try {
|
||||
await removeChatRuntimeJob(normalizedRequestId);
|
||||
} catch (error) {
|
||||
@@ -7390,9 +7549,20 @@ export function MainChatPanel({
|
||||
: item,
|
||||
),
|
||||
);
|
||||
|
||||
if (existingRequest?.parentRequestId?.trim()) {
|
||||
sendReplyToRequest({
|
||||
text: normalizedText,
|
||||
parentRequestId: existingRequest.parentRequestId,
|
||||
mode: 'direct',
|
||||
origin: existingRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handleComposerSendImmediate(normalizedText);
|
||||
},
|
||||
[activeSessionId, handleComposerSendImmediate, messageApi, setRequestItems],
|
||||
[activeSessionId, handleComposerSendImmediate, messageApi, sendReplyToRequest, setRequestItems],
|
||||
);
|
||||
|
||||
const handleToggleImmediateSendPinned = useCallback(() => {
|
||||
@@ -7503,6 +7673,38 @@ export function MainChatPanel({
|
||||
mode: resolvedMode,
|
||||
contextRef: contextRef ?? null,
|
||||
});
|
||||
const queuedRequestId = persistedSelection.queuedRequestId.trim();
|
||||
const submittedAt = new Date().toISOString();
|
||||
|
||||
if (queuedRequestId) {
|
||||
upsertRequestItem({
|
||||
sessionId: targetSessionId,
|
||||
requestId: queuedRequestId,
|
||||
chatTypeId: effectiveChatType.id,
|
||||
chatTypeLabel: effectiveChatType.name,
|
||||
requestOrigin: 'prompt',
|
||||
parentRequestId: parentRequestId.trim(),
|
||||
promptContextRef: contextRef ?? null,
|
||||
status: resolvedMode === 'queue' ? 'queued' : 'accepted',
|
||||
statusMessage: resolvedMode === 'queue' ? 'prompt 후속 요청을 접수했습니다.' : 'prompt 후속 요청을 즉시 접수했습니다.',
|
||||
userMessageId: null,
|
||||
userText: trimmed,
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
hasResponse: false,
|
||||
canDelete: resolvedMode !== 'queue',
|
||||
createdAt: submittedAt,
|
||||
updatedAt: submittedAt,
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
});
|
||||
syncConversationPreviewForRequest(targetSessionId, trimmed, submittedAt, {
|
||||
requestId: queuedRequestId,
|
||||
mode: resolvedMode,
|
||||
queueSize: resolvedMode === 'queue' ? 1 : 0,
|
||||
jobMessage: resolvedMode === 'queue' ? 'prompt 후속 요청 대기 중' : 'prompt 후속 요청 실행 대기 중',
|
||||
});
|
||||
}
|
||||
|
||||
setRequestItems((previous) =>
|
||||
previous.map((item) =>
|
||||
@@ -7562,6 +7764,7 @@ export function MainChatPanel({
|
||||
effectiveRoomCustomContextTitle,
|
||||
executeSendMessageSafely,
|
||||
messageApi,
|
||||
resolvePromptContextRefForRequest,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
],
|
||||
@@ -7569,61 +7772,16 @@ export function MainChatPanel({
|
||||
|
||||
const handleSubmitChildRequest = useCallback(
|
||||
async ({ text, parentRequestId }: { text: string; parentRequestId: string }) => {
|
||||
const trimmed = text.trim();
|
||||
const normalizedParentRequestId = parentRequestId.trim();
|
||||
const targetSessionId = activeSessionId.trim();
|
||||
|
||||
if (!trimmed || !normalizedParentRequestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!effectiveChatType) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 하위 즉시 요청을 전송하지 못했습니다.'),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!targetSessionId) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('활성 대화방이 없어서 하위 즉시 요청을 전송하지 못했습니다.'),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return executeSendMessageSafely({
|
||||
sessionId: targetSessionId,
|
||||
mode: 'direct',
|
||||
text: trimmed,
|
||||
parentRequestId: normalizedParentRequestId,
|
||||
codexModel: effectiveCodexModel,
|
||||
chatTypeId: effectiveChatType.id,
|
||||
chatTypeLabel: effectiveChatType.name,
|
||||
chatTypeDescription: effectiveChatTypeDescription,
|
||||
chatTypeBaseDescription: effectiveChatType.description,
|
||||
defaultContextIds: effectiveDefaultContextIds,
|
||||
defaultContexts: effectiveDefaultContexts,
|
||||
customContextTitle: effectiveRoomCustomContextTitle || null,
|
||||
customContextContent: effectiveRoomCustomContextContent || null,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
return (
|
||||
sendReplyToRequest({
|
||||
text,
|
||||
parentRequestId,
|
||||
mode: 'direct',
|
||||
origin: 'composer',
|
||||
}) === 'sent'
|
||||
);
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createLocalMessage,
|
||||
effectiveCodexModel,
|
||||
effectiveDefaultContextIds,
|
||||
effectiveDefaultContexts,
|
||||
effectiveChatType,
|
||||
effectiveChatTypeDescription,
|
||||
effectiveRoomCustomContextContent,
|
||||
effectiveRoomCustomContextTitle,
|
||||
executeSendMessageSafely,
|
||||
setMessages,
|
||||
],
|
||||
[sendReplyToRequest],
|
||||
);
|
||||
|
||||
const handleCompleteManualRequestBadge = useCallback(
|
||||
@@ -7693,10 +7851,14 @@ export function MainChatPanel({
|
||||
|
||||
executeSendMessageSafely({
|
||||
sessionId: activeSessionId,
|
||||
requestId: request.requestId,
|
||||
mode: 'direct',
|
||||
text: normalizedText,
|
||||
origin: request.requestOrigin === 'prompt' ? 'prompt' : 'composer',
|
||||
parentRequestId: request.requestOrigin === 'prompt' ? request.parentRequestId?.trim() || null : null,
|
||||
parentRequestId: request.parentRequestId?.trim() || null,
|
||||
promptContextRef:
|
||||
normalizePromptContextRef(request.promptContextRef)
|
||||
?? (request.parentRequestId?.trim() ? resolvePromptContextRefForRequest(request.parentRequestId) : null),
|
||||
codexModel: effectiveCodexModel,
|
||||
chatTypeId: effectiveChatType.id,
|
||||
chatTypeLabel: effectiveChatType.name,
|
||||
@@ -7721,6 +7883,7 @@ export function MainChatPanel({
|
||||
effectiveRoomCustomContextTitle,
|
||||
executeSendMessageSafely,
|
||||
messageApi,
|
||||
resolvePromptContextRefForRequest,
|
||||
setMessages,
|
||||
],
|
||||
);
|
||||
@@ -8551,6 +8714,7 @@ export function MainChatPanel({
|
||||
setSelectedCodexModel(normalizeCodexModel(nextCodexModel));
|
||||
}}
|
||||
onSend={handleComposerSend}
|
||||
onSendReplyToResponse={handleSendReplyToResponse}
|
||||
onSendImmediate={handleComposerSendImmediate}
|
||||
isSendWithoutContextEnabled={isSendWithoutContextEnabled}
|
||||
isImmediateSendPinned={isImmediateSendPinned}
|
||||
|
||||
@@ -68,7 +68,6 @@ import {
|
||||
ensureWebPushSubscriptionRegistered,
|
||||
syncExistingWebPushSubscriptionRegistration,
|
||||
} from './webPushRegistration';
|
||||
import { resetNonAuthClientState } from './appMaintenance';
|
||||
import {
|
||||
ALLOWED_REGISTRATION_TOKEN,
|
||||
setRegisteredAccessToken,
|
||||
@@ -158,19 +157,15 @@ type RestartCompletionConfirmOptions = {
|
||||
title: string;
|
||||
targetLabel: string;
|
||||
action: RestartCompletionAction;
|
||||
autoActionSeconds?: number;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
promptText?: string;
|
||||
countdownText?: (remainingSeconds: number, actionLabel: string) => string;
|
||||
customAction?: () => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onActionError?: (message: string) => void;
|
||||
};
|
||||
const SERVER_RESTART_CACHE_BUST_PARAM = '__serverRestartedAt';
|
||||
const RESERVED_RESTART_WORK_SERVER_DELAY_MS = 5_000;
|
||||
const RESERVED_RESTART_VERIFICATION_INTERVAL_MS = 2_000;
|
||||
const RESERVED_RESTART_VERIFICATION_TIMEOUT_MS = 90_000;
|
||||
const RESERVED_RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000;
|
||||
const HEADER_THEME_STORAGE_KEY = 'work-server.header-theme';
|
||||
const DESKTOP_HEADER_HEIGHT_STORAGE_KEY = 'work-server.desktop-header-height';
|
||||
@@ -1104,6 +1099,24 @@ function hasServerRuntimeChanged(previous: ServerCommandItem | null, next: Serve
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkServerRuntimeMarker(item: ServerCommandItem | null) {
|
||||
if (!item) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const runningVersion = item.runningVersion?.trim();
|
||||
if (runningVersion) {
|
||||
return runningVersion;
|
||||
}
|
||||
|
||||
const runningBuiltAt = item.runningBuiltAt?.trim();
|
||||
if (runningBuiltAt) {
|
||||
return runningBuiltAt;
|
||||
}
|
||||
|
||||
return item.startedAt?.trim() || '';
|
||||
}
|
||||
|
||||
function hasServerUpdateStateImproved(previous: ServerCommandItem | null, next: ServerCommandItem | null) {
|
||||
if (!previous || !next) {
|
||||
return next ? !next.buildRequired && !next.updateAvailable : false;
|
||||
@@ -1137,7 +1150,18 @@ function hasServerRestartBeenVerified(
|
||||
}
|
||||
|
||||
if (key === 'work-server') {
|
||||
return Boolean(next.runningVersion ?? next.runningBuiltAt) && !next.buildRequired && !next.updateAvailable;
|
||||
if (next.buildRequired || next.updateAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousRuntimeMarker = getWorkServerRuntimeMarker(previous);
|
||||
const nextRuntimeMarker = getWorkServerRuntimeMarker(next);
|
||||
|
||||
if (!nextRuntimeMarker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return previousRuntimeMarker !== nextRuntimeMarker || hasServerUpdateStateImproved(previous, next);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1174,7 +1198,7 @@ function hasReservedRestartServerBeenVerified(
|
||||
return Boolean(item.runningBuiltAt) && !item.buildRequired;
|
||||
}
|
||||
|
||||
return Boolean(item.runningVersion ?? item.runningBuiltAt) && !item.buildRequired && !item.updateAvailable;
|
||||
return Boolean(getWorkServerRuntimeMarker(item)) && !item.buildRequired && !item.updateAvailable;
|
||||
}
|
||||
|
||||
function shouldResetClientStateAfterRestart(
|
||||
@@ -1591,9 +1615,6 @@ export function MainHeader({
|
||||
const [runtimeLogDetail, setRuntimeLogDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
||||
const [updateCheckFeedback, setUpdateCheckFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [updateCheckCopyFeedback, setUpdateCheckCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [clientResetting, setClientResetting] = useState(false);
|
||||
const [clientResetFeedback, setClientResetFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [testServerStatus, setTestServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
@@ -1612,7 +1633,6 @@ export function MainHeader({
|
||||
const restartProgressAbortRef = useRef<AbortController | null>(null);
|
||||
const serverRestartReservationCompletedBootstrapRef = useRef(false);
|
||||
const handledServerRestartReservationCompletedAtRef = useRef<string | null>(null);
|
||||
const serverRestartReservationReloadTaskIdRef = useRef(0);
|
||||
const { registeredToken, hasAccess } = useTokenAccess();
|
||||
const appConfig = useAppConfig();
|
||||
useEffect(() => {
|
||||
@@ -1759,6 +1779,102 @@ export function MainHeader({
|
||||
serverRestartReservationNowTimestamp,
|
||||
serverRestartReservationReloadPending,
|
||||
);
|
||||
const resolveServerManagementProgressLabel = (key: 'test' | 'work' | 'prod') => {
|
||||
if (
|
||||
(key === 'test' && serverRestartingKey === 'test')
|
||||
|| (key === 'work' && serverRestartingKey === 'work-server')
|
||||
|| (key === 'prod' && serverRestartingKey === 'prod')
|
||||
) {
|
||||
return restartProgress?.stepLabel ?? '재기동 진행 중';
|
||||
}
|
||||
|
||||
if (key === 'prod') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchesReservation = key === 'work'
|
||||
? serverRestartReservation?.target === 'work-server' || serverRestartReservation?.target === 'all'
|
||||
: serverRestartReservation?.target === 'test' || serverRestartReservation?.target === 'all';
|
||||
|
||||
if (!matchesReservation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (serverRestartReservation?.status === 'recovering') {
|
||||
return serverRestartReservation.autoFix.summary?.trim() || 'Codex 자동 개선 중';
|
||||
}
|
||||
|
||||
if (serverRestartReservation?.status === 'executing') {
|
||||
if (serverRestartReservation.executionPhase === 'commit-main-worktree') {
|
||||
return '재기동 실행 준비 중';
|
||||
}
|
||||
|
||||
if (serverRestartReservation.executionPhase === 'verify-runtime') {
|
||||
return '정상 기동 확인 중';
|
||||
}
|
||||
|
||||
if (serverRestartReservation.executionPhase === 'restart-work-server') {
|
||||
return 'WORK 재기동 진행 중';
|
||||
}
|
||||
|
||||
if (serverRestartReservation.executionPhase === 'restart-test') {
|
||||
return 'TEST 재기동 진행 중';
|
||||
}
|
||||
|
||||
return '재기동 진행 중';
|
||||
}
|
||||
|
||||
if (serverRestartReservation?.status === 'ready') {
|
||||
return '자동 실행 대기 중';
|
||||
}
|
||||
|
||||
if (serverRestartReservation?.status === 'waiting') {
|
||||
return '재기동 대기 중';
|
||||
}
|
||||
|
||||
if (serverRestartReservation?.status === 'completed') {
|
||||
return '최근 재기동 완료';
|
||||
}
|
||||
|
||||
if (serverRestartReservation?.status === 'failed') {
|
||||
return '최근 재기동 실패';
|
||||
}
|
||||
|
||||
if (serverRestartReservation?.status === 'cancelled') {
|
||||
return '최근 재기동 취소';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
const serverManagementStatusItems = [
|
||||
{
|
||||
key: 'test',
|
||||
label: 'TEST',
|
||||
item: testServerStatus,
|
||||
checkedLabel: `확인 ${formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}`,
|
||||
detailLabel: `소스 수정일 ${getServerLastSourceChangedDateLabel(testServerStatus)}`,
|
||||
hint: getServerLastSourceChangedHint(testServerStatus),
|
||||
progressLabel: resolveServerManagementProgressLabel('test'),
|
||||
},
|
||||
{
|
||||
key: 'work',
|
||||
label: 'WORK',
|
||||
item: workServerStatus,
|
||||
checkedLabel: `확인 ${formatDateTimeLabel(workServerStatus?.checkedAt ?? null)}`,
|
||||
detailLabel: `소스 수정일 ${getServerLastSourceChangedDateLabel(workServerStatus)}`,
|
||||
hint: getServerLastSourceChangedHint(workServerStatus),
|
||||
progressLabel: resolveServerManagementProgressLabel('work'),
|
||||
},
|
||||
{
|
||||
key: 'prod',
|
||||
label: 'PROD',
|
||||
item: prodServerStatus,
|
||||
checkedLabel: `확인 ${formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)}`,
|
||||
detailLabel: `빌드 시각 ${formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)}`,
|
||||
hint: null,
|
||||
progressLabel: resolveServerManagementProgressLabel('prod'),
|
||||
},
|
||||
] as const;
|
||||
const renderTopMenuOptionLabel = (
|
||||
menu: 'docs' | 'plans' | 'play',
|
||||
label: ReactNode,
|
||||
@@ -1861,8 +1977,11 @@ export function MainHeader({
|
||||
setRuntimeLogLoading(false);
|
||||
}
|
||||
};
|
||||
const canRefreshWorkServerStatus = hasAccess && !workServerStatusLoading && !serverRestartingKey;
|
||||
const canResetClientState = !clientResetting;
|
||||
const canRefreshWorkServerStatus =
|
||||
hasAccess &&
|
||||
!workServerStatusLoading &&
|
||||
!serverRestartReservationLoading &&
|
||||
!serverRestartingKey;
|
||||
const canRestartServers =
|
||||
hasAccess &&
|
||||
!workServerStatusLoading &&
|
||||
@@ -2126,8 +2245,7 @@ export function MainHeader({
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshUpdateTargets(true);
|
||||
void refreshServerRestartReservation(true);
|
||||
void refreshServerManagementStatus(true);
|
||||
}, [hasAccess]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2135,26 +2253,9 @@ export function MainHeader({
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshUpdateTargets(true);
|
||||
void refreshServerRestartReservation(true);
|
||||
void refreshServerManagementStatus(true);
|
||||
}, [activeSettingsModal, hasAccess, settingsModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshServerRestartReservation(true);
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshServerRestartReservation(true);
|
||||
}, serverRestartReservation?.enabled ? 2_000 : 5_000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [hasAccess, serverRestartReservation?.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverRestartReservation) {
|
||||
return;
|
||||
@@ -2180,13 +2281,11 @@ export function MainHeader({
|
||||
useEffect(() => {
|
||||
if (!serverRestartReservation) {
|
||||
setServerRestartReservationReloadPending(false);
|
||||
serverRestartReservationReloadTaskIdRef.current += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverRestartReservation.status !== 'completed') {
|
||||
setServerRestartReservationReloadPending(false);
|
||||
serverRestartReservationReloadTaskIdRef.current += 1;
|
||||
}
|
||||
|
||||
if (!serverRestartReservationCompletedBootstrapRef.current) {
|
||||
@@ -2204,70 +2303,44 @@ export function MainHeader({
|
||||
}
|
||||
|
||||
handledServerRestartReservationCompletedAtRef.current = serverRestartReservation.completedAt;
|
||||
setServerRestartReservationReloadPending(true);
|
||||
setServerRestartReservationFeedback({
|
||||
tone: 'info',
|
||||
message: '예약된 재기동 완료 신호를 받았습니다. TEST/WORK 서버 새 런타임을 최종 확인한 뒤 화면을 새로고침합니다.',
|
||||
});
|
||||
|
||||
const taskId = serverRestartReservationReloadTaskIdRef.current + 1;
|
||||
serverRestartReservationReloadTaskIdRef.current = taskId;
|
||||
|
||||
void (async () => {
|
||||
const deadline = Date.now() + RESERVED_RESTART_VERIFICATION_TIMEOUT_MS;
|
||||
setServerRestartReservationReloadPending(true);
|
||||
setServerRestartReservationFeedback({
|
||||
tone: 'info',
|
||||
message: '예약된 재기동 완료 신호를 받았습니다. 자동 새로고침 없이 현재 상태만 한 번 다시 확인합니다.',
|
||||
});
|
||||
|
||||
while (serverRestartReservationReloadTaskIdRef.current === taskId && Date.now() <= deadline) {
|
||||
try {
|
||||
const statuses = await refreshServerStatuses();
|
||||
const testVerified = hasReservedRestartServerBeenVerified('test', statuses.test, serverRestartReservation.startedAt);
|
||||
const workVerified = hasReservedRestartServerBeenVerified(
|
||||
'work-server',
|
||||
statuses['work-server'],
|
||||
serverRestartReservation.startedAt,
|
||||
);
|
||||
try {
|
||||
const statuses = await refreshServerStatuses();
|
||||
const testVerified = hasReservedRestartServerBeenVerified('test', statuses.test, serverRestartReservation.startedAt);
|
||||
const workVerified = hasReservedRestartServerBeenVerified(
|
||||
'work-server',
|
||||
statuses['work-server'],
|
||||
serverRestartReservation.startedAt,
|
||||
);
|
||||
|
||||
setServerRestartReservationReloadPending(false);
|
||||
if (testVerified && workVerified) {
|
||||
setServerRestartReservationFeedback({
|
||||
tone: 'info',
|
||||
message: '예약된 재기동 뒤 TEST/WORK 서버 새 런타임을 확인했습니다. 캐시를 정리한 뒤 화면을 새로고침합니다.',
|
||||
});
|
||||
try {
|
||||
await executeRestartCompletionAction('reset-client-state');
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '재기동 뒤 브라우저 상태를 정리하지 못했습니다.';
|
||||
setServerRestartReservationReloadPending(false);
|
||||
setServerRestartReservationFeedback({
|
||||
tone: 'error',
|
||||
message,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingTargets = [
|
||||
!testVerified ? 'TEST' : null,
|
||||
!workVerified ? 'WORK' : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
setServerRestartReservationFeedback({
|
||||
tone: 'info',
|
||||
message: `${pendingTargets.join('/')} 서버 새 런타임 확인 전이라 새로고침을 보류합니다.`,
|
||||
});
|
||||
} catch {
|
||||
setServerRestartReservationFeedback({
|
||||
tone: 'info',
|
||||
message: '재기동 뒤 서버 응답을 다시 확인하는 중입니다. 확인 전까지 새로고침을 보류합니다.',
|
||||
tone: 'success',
|
||||
message: '예약된 재기동 뒤 TEST/WORK 서버 새 런타임을 확인했습니다. 필요할 때만 화면 새로고침을 직접 실행해 주세요.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForDuration(RESERVED_RESTART_VERIFICATION_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (serverRestartReservationReloadTaskIdRef.current === taskId) {
|
||||
const pendingTargets = [
|
||||
!testVerified ? 'TEST' : null,
|
||||
!workVerified ? 'WORK' : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
setServerRestartReservationFeedback({
|
||||
tone: 'warning',
|
||||
message: `${pendingTargets.join('/')} 서버 새 런타임은 아직 확인되지 않았습니다. 자동 재시도 없이 상태 새로고침에서 다시 확인해 주세요.`,
|
||||
});
|
||||
} catch {
|
||||
setServerRestartReservationReloadPending(false);
|
||||
setServerRestartReservationFeedback({
|
||||
tone: 'warning',
|
||||
message: '재기동 완료 뒤 새 런타임 최종 확인이 지연되어 자동 새로고침을 보류했습니다.',
|
||||
message: '재기동 뒤 상태를 한 번 다시 확인하지 못했습니다. 자동 재시도 없이 상태 새로고침에서 다시 확인해 주세요.',
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -2498,6 +2571,13 @@ export function MainHeader({
|
||||
}
|
||||
};
|
||||
|
||||
const refreshServerManagementStatus = async (silent = false) => {
|
||||
await Promise.all([
|
||||
refreshUpdateTargets(silent),
|
||||
refreshServerRestartReservation(silent),
|
||||
]);
|
||||
};
|
||||
|
||||
const waitForServerRestart = async (
|
||||
key: 'test' | 'prod' | 'work-server',
|
||||
baseline: ServerCommandItem | null,
|
||||
@@ -2581,43 +2661,6 @@ export function MainHeader({
|
||||
};
|
||||
};
|
||||
|
||||
const handleResetClientState = async () => {
|
||||
if (clientResetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClientResetCopyFeedback(null);
|
||||
setClientResetFeedback(null);
|
||||
setClientResetting(true);
|
||||
|
||||
try {
|
||||
const result = await resetNonAuthClientState();
|
||||
const changedCount =
|
||||
result.removedLocalStorageKeys.length +
|
||||
result.removedSessionStorageKeys.length +
|
||||
result.removedCacheKeys.length +
|
||||
result.unregisteredServiceWorkerCount;
|
||||
|
||||
setClientResetFeedback({
|
||||
tone: 'success',
|
||||
message:
|
||||
changedCount > 0
|
||||
? `토큰/권한 정보는 유지하고 캐시·스토리지를 초기화했습니다. 변경 ${changedCount}건을 정리한 뒤 화면을 새로고침합니다.`
|
||||
: '토큰/권한 정보는 유지하고 초기화 대상 캐시·스토리지를 확인했으며, 화면을 새로고침합니다.',
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
window.location.replace(buildCacheBustedReloadUrl());
|
||||
}, 700);
|
||||
} catch (error) {
|
||||
setClientResetFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : '캐시·스토리지 초기화에 실패했습니다.',
|
||||
});
|
||||
} finally {
|
||||
setClientResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const executeRestartCompletionAction = async (action: RestartCompletionAction) => {
|
||||
if (action === 'reset-client-state') {
|
||||
await resetNonAuthClientState();
|
||||
@@ -2637,47 +2680,29 @@ export function MainHeader({
|
||||
title,
|
||||
targetLabel,
|
||||
action,
|
||||
autoActionSeconds = 4,
|
||||
okText,
|
||||
cancelText,
|
||||
promptText,
|
||||
countdownText,
|
||||
customAction,
|
||||
onCancel,
|
||||
onActionError,
|
||||
} = options;
|
||||
const actionLabel = getRestartCompletionActionLabel(action);
|
||||
let remainingSeconds = Math.max(1, Math.round(autoActionSeconds));
|
||||
let settled = false;
|
||||
let countdownTimerId: number | null = null;
|
||||
let autoActionTimerId: number | null = null;
|
||||
|
||||
const buildContent = (seconds: number) => (
|
||||
const buildContent = () => (
|
||||
<Space direction="vertical" size={8}>
|
||||
<Text>{promptText ?? `실제로 ${targetLabel} 부팅 완료까지 확인했습니다. 지금 ${actionLabel.toLowerCase()}할까요?`}</Text>
|
||||
<Text type="secondary">
|
||||
{countdownText?.(seconds, actionLabel) ?? `${seconds}초 동안 반응이 없으면 자동으로 ${actionLabel.toLowerCase()}합니다.`}
|
||||
</Text>
|
||||
<Text type="secondary">자동으로 진행하지 않습니다. 필요할 때만 직접 선택해 주세요.</Text>
|
||||
</Space>
|
||||
);
|
||||
|
||||
const cleanupTimers = () => {
|
||||
if (countdownTimerId !== null) {
|
||||
window.clearInterval(countdownTimerId);
|
||||
}
|
||||
|
||||
if (autoActionTimerId !== null) {
|
||||
window.clearTimeout(autoActionTimerId);
|
||||
}
|
||||
};
|
||||
|
||||
const runAction = async () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
cleanupTimers();
|
||||
try {
|
||||
if (action === 'custom') {
|
||||
await customAction?.();
|
||||
@@ -2700,7 +2725,7 @@ export function MainHeader({
|
||||
|
||||
const confirmModal = modalApi.confirm({
|
||||
title,
|
||||
content: buildContent(remainingSeconds),
|
||||
content: buildContent(),
|
||||
okText: okText ?? actionLabel,
|
||||
cancelText: cancelText ?? '나중에',
|
||||
autoFocusButton: 'ok',
|
||||
@@ -2708,31 +2733,9 @@ export function MainHeader({
|
||||
onOk: () => runAction(),
|
||||
onCancel: () => {
|
||||
settled = true;
|
||||
cleanupTimers();
|
||||
onCancel?.();
|
||||
},
|
||||
});
|
||||
|
||||
countdownTimerId = window.setInterval(() => {
|
||||
if (settled) {
|
||||
cleanupTimers();
|
||||
return;
|
||||
}
|
||||
|
||||
remainingSeconds = Math.max(0, remainingSeconds - 1);
|
||||
confirmModal.update({
|
||||
content: buildContent(remainingSeconds),
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
autoActionTimerId = window.setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
confirmModal.destroy();
|
||||
void runAction();
|
||||
}, remainingSeconds * 1000);
|
||||
};
|
||||
|
||||
const restartServerWithVerification = async (
|
||||
@@ -2765,7 +2768,7 @@ export function MainHeader({
|
||||
|
||||
updateRestartProgress(
|
||||
progressTaskId,
|
||||
'재기동 요청 완료',
|
||||
'재기동 요청 접수',
|
||||
`${targetLabel} 재기동 요청이 접수되었습니다. 실제 부팅 완료를 확인할 때까지 기다립니다.`,
|
||||
);
|
||||
const verificationBaseline = result.restartState === 'accepted' ? result.item : baseline;
|
||||
@@ -4856,6 +4859,16 @@ export function MainHeader({
|
||||
open={settingsModalOpen}
|
||||
footer={null}
|
||||
confirmLoading={notificationLoading}
|
||||
width={activeSettingsModal === 'update' && isMobileViewport ? 'calc(100vw - 16px)' : undefined}
|
||||
styles={
|
||||
activeSettingsModal === 'update'
|
||||
? {
|
||||
body: {
|
||||
padding: isMobileViewport ? 16 : 24,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onCancel={() => {
|
||||
setSettingsModalOpen(false);
|
||||
}}
|
||||
@@ -4950,156 +4963,115 @@ export function MainHeader({
|
||||
</Space>
|
||||
) : null}
|
||||
{activeSettingsModal === 'update' ? (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text strong>업데이트 확인</Text>
|
||||
<Text type="secondary">
|
||||
테스트
|
||||
<span
|
||||
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(testServerStatus)}`}
|
||||
aria-label={getServerVersionStatusTitle(testServerStatus, '테스트')}
|
||||
title={getServerVersionStatusTitle(testServerStatus, '테스트')}
|
||||
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
소스 수정일: {getServerLastSourceChangedDateLabel(testServerStatus)}
|
||||
</Text>
|
||||
{getServerLastSourceChangedHint(testServerStatus) ? (
|
||||
<Text type="secondary">{getServerLastSourceChangedHint(testServerStatus)}</Text>
|
||||
) : null}
|
||||
<Text type="secondary">
|
||||
워크
|
||||
<span
|
||||
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(workServerStatus)}`}
|
||||
aria-label={getServerVersionStatusTitle(workServerStatus, '워크')}
|
||||
title={getServerVersionStatusTitle(workServerStatus, '워크')}
|
||||
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
소스 수정일: {getServerLastSourceChangedDateLabel(workServerStatus)}
|
||||
</Text>
|
||||
{getServerLastSourceChangedHint(workServerStatus) ? (
|
||||
<Text type="secondary">{getServerLastSourceChangedHint(workServerStatus)}</Text>
|
||||
) : null}
|
||||
<div className="app-header__server-management">
|
||||
<div className="app-header__server-management-hero">
|
||||
<div className="app-header__server-management-hero-copy">
|
||||
<Text strong>서버 재기동</Text>
|
||||
<Text type="secondary">핵심 작업을 먼저 두고 상태는 한 화면에서 바로 확인하게 정리했습니다.</Text>
|
||||
</div>
|
||||
<Button
|
||||
icon={
|
||||
workServerStatusLoading || serverRestartReservationLoading ? <ReloadOutlined spin /> : <ReloadOutlined />
|
||||
}
|
||||
loading={workServerStatusLoading || serverRestartReservationLoading}
|
||||
disabled={!canRefreshWorkServerStatus}
|
||||
onClick={() => {
|
||||
void refreshServerManagementStatus();
|
||||
}}
|
||||
>
|
||||
상태 새로고침
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-header__server-management-status-grid">
|
||||
{serverManagementStatusItems.map((statusItem) => (
|
||||
<div key={statusItem.key} className="app-header__server-management-status-card">
|
||||
<div className="app-header__server-management-status-head">
|
||||
<Text strong>{statusItem.label}</Text>
|
||||
<span
|
||||
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(statusItem.item)}`}
|
||||
aria-label={getServerVersionStatusTitle(statusItem.item, statusItem.label)}
|
||||
title={getServerVersionStatusTitle(statusItem.item, statusItem.label)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary">{statusItem.checkedLabel}</Text>
|
||||
<Text type="secondary">{statusItem.detailLabel}</Text>
|
||||
{statusItem.progressLabel ? <Text type="secondary">진행상태 {statusItem.progressLabel}</Text> : null}
|
||||
{statusItem.hint ? <Text type="secondary">{statusItem.hint}</Text> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{workServerStatus?.buildRequired ? (
|
||||
<Text type="danger">
|
||||
빨간 점은 장애가 아니라 빌드 미반영 상태입니다. 워크 재기동만으로는 그대로 남을 수 있습니다.
|
||||
</Text>
|
||||
) : null}
|
||||
<Text type="secondary">
|
||||
운영
|
||||
<span
|
||||
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(prodServerStatus)}`}
|
||||
aria-label={getServerVersionStatusTitle(prodServerStatus, '운영')}
|
||||
title={getServerVersionStatusTitle(prodServerStatus, '운영')}
|
||||
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
|
||||
aria-hidden="true"
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="WORK 빨간 점은 장애가 아니라 빌드 미반영 상태입니다. 워크 재기동만으로는 그대로 남을 수 있습니다."
|
||||
/>
|
||||
</Text>
|
||||
<Text type="secondary">{formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)}</Text>
|
||||
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
|
||||
<Button
|
||||
block
|
||||
icon={workServerStatusLoading ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={workServerStatusLoading}
|
||||
disabled={!canRefreshWorkServerStatus}
|
||||
onClick={() => {
|
||||
void refreshUpdateTargets();
|
||||
}}
|
||||
>
|
||||
업데이트 확인
|
||||
</Button>
|
||||
<Text strong style={{ marginTop: 8 }}>
|
||||
캐시 / 스토리지 초기화
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
권한, 앱 설정, 푸시/서비스워커 등록은 유지하고 화면 상태와 앱 캐시만 초기화합니다.
|
||||
</Text>
|
||||
{renderFeedback(clientResetFeedback, clientResetCopyFeedback, setClientResetCopyFeedback)}
|
||||
<Button
|
||||
block
|
||||
danger
|
||||
icon={clientResetting ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={clientResetting}
|
||||
disabled={!canResetClientState}
|
||||
onClick={() => {
|
||||
void handleResetClientState();
|
||||
}}
|
||||
>
|
||||
{clientResetting ? '초기화 진행 중' : '초기화'}
|
||||
</Button>
|
||||
<Text strong style={{ marginTop: 8 }}>
|
||||
서버 재기동
|
||||
</Text>
|
||||
<Text type="secondary">전체 재기동은 TEST와 WORK 서버만 순서대로 예약 실행합니다.</Text>
|
||||
<Text type="secondary">
|
||||
테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
워크 마지막 확인: {formatDateTimeLabel(workServerStatus?.checkedAt ?? null)}
|
||||
</Text>
|
||||
{!hasAccess ? (
|
||||
<Text type="warning">서버 재기동은 권한 토큰 등록 후 사용할 수 있습니다.</Text>
|
||||
) : null}
|
||||
{!hasAccess ? (
|
||||
<Alert showIcon type="warning" message="서버 재기동은 권한 토큰 등록 후 사용할 수 있습니다." />
|
||||
) : null}
|
||||
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
|
||||
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
|
||||
<Space direction={screens.xs ? 'vertical' : 'horizontal'} style={{ width: '100%' }}>
|
||||
<Button
|
||||
block={screens.xs}
|
||||
icon={serverRestartingKey === 'test' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'test'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={() => {
|
||||
void handleRestartSingleServer('test');
|
||||
}}
|
||||
>
|
||||
테스트 재기동
|
||||
</Button>
|
||||
<Button
|
||||
block={screens.xs}
|
||||
icon={serverRestartingKey === 'work-server' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'work-server'}
|
||||
disabled={!canRestartServers || serverRestartReservationLoading}
|
||||
onClick={() => {
|
||||
void handleScheduleServerRestartReservation('work-server');
|
||||
}}
|
||||
>
|
||||
워크 재기동 예약
|
||||
</Button>
|
||||
<div className="app-header__server-management-action-group">
|
||||
<Text strong>즉시 실행</Text>
|
||||
<div className="app-header__server-management-action-grid">
|
||||
<Button
|
||||
block
|
||||
icon={serverRestartingKey === 'test' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'test'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={() => {
|
||||
void handleRestartSingleServer('test');
|
||||
}}
|
||||
>
|
||||
테스트 재기동
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
icon={serverRestartingKey === 'work-server' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'work-server'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={() => {
|
||||
void handleRestartSingleServer('work-server');
|
||||
}}
|
||||
>
|
||||
워크 재기동
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
className="app-header__server-management-action-grid-primary"
|
||||
icon={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'all' || serverRestartReservationLoading}
|
||||
disabled={!canRestartServers || serverRestartReservationLoading}
|
||||
onClick={() => {
|
||||
void handleScheduleServerRestartReservation('all');
|
||||
}}
|
||||
>
|
||||
전체 재기동 예약
|
||||
</Button>
|
||||
</div>
|
||||
<Text type="secondary">전체 재기동은 TEST와 WORK 서버만 순서대로 예약 실행합니다.</Text>
|
||||
</div>
|
||||
<div className="app-header__server-management-action-group">
|
||||
<Text strong>운영 반영</Text>
|
||||
<Text type="secondary">
|
||||
PROD 컨테이너는 전체 재기동에 포함하지 않고, 별도 확인 후 빌드와 재기동을 진행합니다.
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
block={screens.xs}
|
||||
icon={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'all' || serverRestartReservationLoading}
|
||||
disabled={!canRestartServers || serverRestartReservationLoading}
|
||||
onClick={() => {
|
||||
void handleScheduleServerRestartReservation('all');
|
||||
}}
|
||||
danger
|
||||
block
|
||||
icon={serverRestartingKey === 'prod' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'prod'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={handleConfirmRestartProdServer}
|
||||
>
|
||||
전체 재기동 예약
|
||||
PROD 빌드 반영
|
||||
</Button>
|
||||
</Space>
|
||||
<Text strong style={{ marginTop: 8 }}>
|
||||
PROD 빌드 반영
|
||||
</Text>
|
||||
<Text type="secondary">운영 마지막 확인: {formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)}</Text>
|
||||
<Text type="secondary">
|
||||
PROD 컨테이너는 전체 재기동에 포함하지 않고, 별도 확인 후 빌드와 재기동을 진행합니다.
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
block
|
||||
icon={serverRestartingKey === 'prod' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'prod'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={handleConfirmRestartProdServer}
|
||||
>
|
||||
PROD 빌드 반영
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
@@ -850,6 +850,71 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-header__server-management {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-header__server-management-hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-header__server-management-hero-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-header__server-management-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-header__server-management-status-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.app-header__server-management-status-card .ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.app-header__server-management-status-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__server-management-action-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__server-management-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__server-management-action-grid-primary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.app-header__runtime-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1289,6 +1354,15 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.server-command-page),
|
||||
.app-shell:has(.server-command-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.server-command-page),
|
||||
.app-main-panel:has(.server-command-page),
|
||||
.app-main-layout:has(.server-command-page) {
|
||||
overscroll-behavior: none;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.layout-draw-page) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
@@ -2798,6 +2872,22 @@ body.preview-app-overlay-console-dragging {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__server-management-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header__server-management-hero > .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-header__server-management-status-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.app-header__server-management-action-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-sider.ant-layout-sider {
|
||||
position: static;
|
||||
}
|
||||
|
||||
@@ -94,13 +94,14 @@ export function SharedChatManagementPage() {
|
||||
];
|
||||
const openManagedShareWindow = (url: string) => {
|
||||
openExternalLinkInNewWindow(url, {
|
||||
allowSameTabFallback: false,
|
||||
onUnsupportedStandalone: (fallbackUrl) => {
|
||||
void copyTextToClipboard(fallbackUrl)
|
||||
.then(() => {
|
||||
message.info('현재 모바일 PWA에서는 preview 앱 열기도 막혀 URL을 복사했습니다. 브라우저에서 붙여 열어 주세요.');
|
||||
message.info('현재 창은 유지하고 공유 URL만 복사했습니다. 브라우저나 새 PWA 창에서 붙여 열어 주세요.');
|
||||
})
|
||||
.catch(() => {
|
||||
message.info('현재 모바일 PWA에서는 새 창과 preview 앱 열기가 막힐 수 있습니다. URL 복사 후 브라우저에서 열어 주세요.');
|
||||
message.info('현재 창은 유지했습니다. 새 창 열기가 막히면 URL 복사 후 브라우저에서 열어 주세요.');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -382,6 +382,39 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.shared-resource-management-page__conversation-drawer .ant-drawer-content-wrapper {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.shared-resource-management-page__conversation-drawer .ant-drawer-header {
|
||||
padding: 10px 16px 9px;
|
||||
}
|
||||
|
||||
.shared-resource-management-page__conversation-drawer .ant-drawer-title {
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.shared-resource-management-page__conversation-drawer .ant-drawer-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shared-resource-management-page__conversation-drawer .ant-drawer-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.shared-resource-management-page__conversation-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 48px);
|
||||
border: 0;
|
||||
background: #fff;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shared-resource-management-page__summary-strip {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DeleteOutlined, LinkOutlined, PlusOutlined, QrcodeOutlined, ReloadOutlined, SaveOutlined, StopOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Alert, App, Button, Card, Checkbox, Empty, Flex, Form, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState, type Key, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Alert, App, Button, Card, Checkbox, Drawer, Empty, Flex, Form, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
|
||||
import { useEffect, useLayoutEffect, useMemo, useState, type Key, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type SharedResourceTokenRecord,
|
||||
type SharedResourceType,
|
||||
} from './sharedResourceTokenAccess';
|
||||
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from './pwa/installManifest';
|
||||
import './SharedResourceManagementPage.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
@@ -45,6 +46,7 @@ const RESOURCE_TYPE_OPTIONS: Array<{ value: SharedResourceType; label: string }>
|
||||
const MANAGEMENT_APP_OPTIONS = [
|
||||
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
|
||||
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
|
||||
{ value: 'text-memo-widget', label: '메모', description: '공유채팅 Apps에서 메모 컴포넌트 실행', category: '관리' },
|
||||
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
|
||||
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
|
||||
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
|
||||
@@ -83,6 +85,12 @@ type SharedResourceManagementSharedAccess = {
|
||||
managedResourceTokenId?: string | null;
|
||||
};
|
||||
|
||||
type ConversationDrawerState = {
|
||||
tokenId: string;
|
||||
tokenName: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function isChatShareToken<T extends Pick<SharedResourceTokenRecord, 'resourceType'>>(
|
||||
item: T | null | undefined,
|
||||
): item is T & { resourceType: 'chat-share' } {
|
||||
@@ -105,6 +113,14 @@ type SharedResourceTokenFormValue = {
|
||||
usageLimit: number;
|
||||
};
|
||||
|
||||
const SHARED_RESOURCE_INSTALL_THEME_COLOR = '#0f766e';
|
||||
const SHARED_RESOURCE_INSTALL_BACKGROUND_COLOR = '#f3fbf9';
|
||||
const SHARED_RESOURCE_INSTALL_TITLE = '공유 리소스 관리';
|
||||
|
||||
function buildSharedResourceInstallTitle(isSharedManageMode: boolean) {
|
||||
return isSharedManageMode ? '공유 리소스 관리 링크' : SHARED_RESOURCE_INSTALL_TITLE;
|
||||
}
|
||||
|
||||
const EMPTY_FORM_VALUE: SharedResourceTokenFormValue = {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -417,6 +433,12 @@ function buildChatConversationUrl(item: Pick<SharedResourceTokenRecord, 'resourc
|
||||
return null;
|
||||
}
|
||||
|
||||
const shareUrl = buildAbsoluteShareUrl(item.sharePath);
|
||||
|
||||
if (shareUrl !== '-') {
|
||||
return shareUrl;
|
||||
}
|
||||
|
||||
const sessionId = resolveChatShareSessionId(item.sharePath || item.resourcePath);
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -470,9 +492,11 @@ function SharedResourceQrPanel({
|
||||
export function SharedResourceManagementPage({
|
||||
sharedPreview = null,
|
||||
sharedAccess = null,
|
||||
disableInstallMetadata = false,
|
||||
}: {
|
||||
sharedPreview?: SharedResourceManagementSharedPreview | null;
|
||||
sharedAccess?: SharedResourceManagementSharedAccess | null;
|
||||
disableInstallMetadata?: boolean;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const { hasAccess } = useTokenAccess();
|
||||
@@ -491,9 +515,43 @@ export function SharedResourceManagementPage({
|
||||
const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'settings' | 'history'>('basic');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
const [qrPreviewTokenId, setQrPreviewTokenId] = useState<string | null>(null);
|
||||
const [conversationDrawer, setConversationDrawer] = useState<ConversationDrawerState | null>(null);
|
||||
const [conversationDrawerKey, setConversationDrawerKey] = useState(0);
|
||||
const [form] = Form.useForm<SharedResourceTokenFormValue>();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (disableInstallMetadata || typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
const installTitle = buildSharedResourceInstallTitle(isSharedManageMode);
|
||||
const manifestObjectUrl = createInstallManifestObjectUrl({
|
||||
startPath,
|
||||
scope: window.location.pathname,
|
||||
name: installTitle,
|
||||
shortName: '공유 리소스',
|
||||
description: isSharedManageMode
|
||||
? '공유 리소스 관리 링크를 홈 화면 앱으로 바로 엽니다.'
|
||||
: '공유 리소스 관리 화면을 홈 화면 앱으로 바로 엽니다.',
|
||||
themeColor: SHARED_RESOURCE_INSTALL_THEME_COLOR,
|
||||
backgroundColor: SHARED_RESOURCE_INSTALL_BACKGROUND_COLOR,
|
||||
});
|
||||
const restoreManifest = swapInstallDocumentMetadata({
|
||||
manifestHref: manifestObjectUrl,
|
||||
title: installTitle,
|
||||
themeColor: SHARED_RESOURCE_INSTALL_THEME_COLOR,
|
||||
});
|
||||
|
||||
return () => {
|
||||
restoreManifest();
|
||||
if (manifestObjectUrl) {
|
||||
window.URL.revokeObjectURL(manifestObjectUrl);
|
||||
}
|
||||
};
|
||||
}, [disableInstallMetadata, isSharedManageMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
@@ -632,17 +690,26 @@ export function SharedResourceManagementPage({
|
||||
const openConversationWindow = (url: string, event?: ReactMouseEvent<HTMLElement>) => {
|
||||
openExternalLinkInNewWindow(url, {
|
||||
event,
|
||||
allowSameTabFallback: false,
|
||||
onUnsupportedStandalone: (fallbackUrl) => {
|
||||
void copyTextToClipboard(fallbackUrl)
|
||||
.then(() => {
|
||||
message.info('현재 모바일 PWA에서는 preview 앱 열기도 막혀 URL을 복사했습니다. 브라우저에서 붙여 열어 주세요.');
|
||||
message.info('현재 창은 유지하고 공유채팅 URL만 복사했습니다. 브라우저나 새 PWA 창에서 붙여 열어 주세요.');
|
||||
})
|
||||
.catch(() => {
|
||||
message.info('현재 모바일 PWA에서는 새 창과 preview 앱 열기가 막힐 수 있습니다. QR 코드나 URL 복사로 이어서 열어 주세요.');
|
||||
message.info('현재 창은 유지했습니다. 새 창 열기가 막히면 QR 코드나 URL 복사로 이어서 열어 주세요.');
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
const openConversationDrawer = (tokenId: string, tokenName: string, url: string) => {
|
||||
setConversationDrawer({
|
||||
tokenId,
|
||||
tokenName,
|
||||
url,
|
||||
});
|
||||
setConversationDrawerKey((current) => current + 1);
|
||||
};
|
||||
|
||||
const listColumns = useMemo(
|
||||
() => [
|
||||
@@ -740,7 +807,7 @@ export function SharedResourceManagementPage({
|
||||
return;
|
||||
}
|
||||
|
||||
openConversationWindow(conversationUrl, event);
|
||||
openConversationDrawer(item.id, item.name, conversationUrl);
|
||||
}}
|
||||
>
|
||||
열기
|
||||
@@ -796,6 +863,21 @@ export function SharedResourceManagementPage({
|
||||
key: 'detail',
|
||||
render: (value: string | null) => sanitizeActivityDetail(value) ?? '-',
|
||||
},
|
||||
{
|
||||
title: '접속 정보',
|
||||
key: 'ip',
|
||||
render: (_value: unknown, item: SharedResourceTokenActivityRecord) => {
|
||||
const lines = [
|
||||
item.externalIp ? `외부 ${item.externalIp}` : null,
|
||||
item.clientIp ? `서버 ${item.clientIp}` : null,
|
||||
item.forwardedFor ? `XFF ${item.forwardedFor}` : null,
|
||||
item.clientId ? `client ${item.clientId}` : null,
|
||||
].filter(Boolean);
|
||||
return lines.length > 0 ? (
|
||||
<Typography.Text style={{ whiteSpace: 'pre-line' }}>{lines.join('\n')}</Typography.Text>
|
||||
) : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '사용량',
|
||||
dataIndex: 'usageDelta',
|
||||
@@ -1148,6 +1230,38 @@ export function SharedResourceManagementPage({
|
||||
<Empty description="QR 코드를 표시할 공유 URL이 없습니다." />
|
||||
)}
|
||||
</Modal>
|
||||
<Drawer
|
||||
open={Boolean(conversationDrawer)}
|
||||
title={conversationDrawer ? `${conversationDrawer.tokenName} 채팅` : '공유 채팅'}
|
||||
placement="right"
|
||||
width="100vw"
|
||||
rootClassName="shared-resource-management-page__conversation-drawer"
|
||||
onClose={() => {
|
||||
setConversationDrawer(null);
|
||||
}}
|
||||
extra={
|
||||
conversationDrawer ? (
|
||||
<Space size={8}>
|
||||
<Button onClick={() => setConversationDrawerKey((current) => current + 1)}>새로고침</Button>
|
||||
<Button onClick={(event) => openConversationWindow(conversationDrawer.url, event)}>새 창</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{conversationDrawer ? (
|
||||
<iframe
|
||||
key={`${conversationDrawer.tokenId}-${conversationDrawerKey}`}
|
||||
title={`${conversationDrawer.tokenName} 공유 채팅`}
|
||||
src={conversationDrawer.url}
|
||||
className="shared-resource-management-page__conversation-frame"
|
||||
/>
|
||||
) : null}
|
||||
</Drawer>
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="공유 리소스 관리"
|
||||
@@ -1422,9 +1536,9 @@ export function SharedResourceManagementPage({
|
||||
type="link"
|
||||
icon={<LinkOutlined />}
|
||||
style={{ paddingInline: 0, marginTop: 4 }}
|
||||
onClick={(event) => openConversationWindow(detailConversationUrl, event)}
|
||||
onClick={() => openConversationDrawer(detailData.token.id, detailData.token.name, detailConversationUrl)}
|
||||
>
|
||||
채팅창 새 창 열기
|
||||
채팅창 열기
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
scrollbar-gutter: stable;
|
||||
scroll-padding-bottom: calc(var(--chat-share-page-bottom-padding) + var(--chat-share-page-active-safe-bottom));
|
||||
padding:
|
||||
calc(var(--chat-share-page-top-padding) + var(--chat-share-page-safe-top))
|
||||
@@ -1280,6 +1280,8 @@
|
||||
|
||||
.chat-share-page__conversation-panel > .chat-share-page__section-head {
|
||||
top: 0;
|
||||
margin: -8px -8px 8px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.chat-share-page__conversation-panel,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Alert, App, Button, Card, Checkbox, Descriptions, Empty, Form, Input, InputNumber, List, Modal, Space, Switch, Tabs, Tag, Typography } from 'antd';
|
||||
import { Alert, App, Button, Card, Checkbox, Descriptions, Empty, Form, Input, InputNumber, List, Modal, Space, Switch, Table, Tabs, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
|
||||
import { confirmWithKeyboard } from './modalKeyboard';
|
||||
import {
|
||||
deleteTokenSetting,
|
||||
fetchTokenSettingActivities,
|
||||
type TokenSettingActivityRecord,
|
||||
type TokenSettingRecord,
|
||||
upsertTokenSetting,
|
||||
useTokenSettingRegistry,
|
||||
@@ -54,6 +56,7 @@ type SharedTokenSettingAccess = {
|
||||
const MANAGEMENT_APP_OPTIONS: AppOption[] = [
|
||||
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
|
||||
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
|
||||
{ value: 'text-memo-widget', label: '메모', description: '공유채팅 Apps에서 메모 컴포넌트 실행', category: '관리' },
|
||||
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
|
||||
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
|
||||
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
|
||||
@@ -219,7 +222,9 @@ export function TokenSettingManagementPage({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [saveSuccessMessage, setSaveSuccessMessage] = useState('');
|
||||
const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'quota' | 'apps'>('basic');
|
||||
const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'quota' | 'apps' | 'history'>('basic');
|
||||
const [activities, setActivities] = useState<TokenSettingActivityRecord[]>([]);
|
||||
const [isActivityLoading, setIsActivityLoading] = useState(false);
|
||||
const [form] = Form.useForm<TokenSettingFormValue>();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const lastHydratedFormKeyRef = useRef('');
|
||||
@@ -264,6 +269,83 @@ export function TokenSettingManagementPage({
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : effectiveSelectedTokenSetting));
|
||||
}, [detailMode, effectiveSelectedTokenSetting?.id, form, isCreating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail' || isCreating || !effectiveSelectedTokenSetting?.id || isSharedPreviewMode) {
|
||||
setActivities([]);
|
||||
setIsActivityLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsActivityLoading(true);
|
||||
|
||||
void fetchTokenSettingActivities(
|
||||
effectiveSelectedTokenSetting.id,
|
||||
isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined,
|
||||
)
|
||||
.then((nextItems) => {
|
||||
if (!cancelled) {
|
||||
setActivities(nextItems);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setActivities([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsActivityLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detailMode, effectiveSelectedTokenSetting?.id, isCreating, isSharedManageMode, isSharedPreviewMode, sharedAccess?.shareToken]);
|
||||
|
||||
const activityColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '시각',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (value: string) => new Date(value).toLocaleString('ko-KR'),
|
||||
},
|
||||
{
|
||||
title: '유형',
|
||||
dataIndex: 'activityType',
|
||||
key: 'activityType',
|
||||
render: (value: TokenSettingActivityRecord['activityType']) => <Tag>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '내용',
|
||||
dataIndex: 'summary',
|
||||
key: 'summary',
|
||||
},
|
||||
{
|
||||
title: '변경 상세',
|
||||
dataIndex: 'detail',
|
||||
key: 'detail',
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
key: 'ip',
|
||||
render: (_value: unknown, item: TokenSettingActivityRecord) => {
|
||||
const lines = [
|
||||
item.externalIp ? `외부 ${item.externalIp}` : null,
|
||||
item.clientIp ? `서버 ${item.clientIp}` : null,
|
||||
item.forwardedFor ? `XFF ${item.forwardedFor}` : null,
|
||||
item.clientId ? `client ${item.clientId}` : null,
|
||||
].filter(Boolean);
|
||||
return lines.length > 0 ? <Text style={{ whiteSpace: 'pre-line' }}>{lines.join('\n')}</Text> : '-';
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const openCreateForm = () => {
|
||||
setIsCreating(true);
|
||||
setSelectedTokenSettingId(null);
|
||||
@@ -522,7 +604,7 @@ export function TokenSettingManagementPage({
|
||||
<div className="chat-type-management-page__editor-scroll">
|
||||
<Tabs
|
||||
activeKey={activeDetailTab}
|
||||
onChange={(key) => setActiveDetailTab(key as 'basic' | 'quota' | 'apps')}
|
||||
onChange={(key) => setActiveDetailTab(key as 'basic' | 'quota' | 'apps' | 'history')}
|
||||
className="token-setting-management-page__detail-tabs"
|
||||
items={[
|
||||
{
|
||||
@@ -680,6 +762,35 @@ export function TokenSettingManagementPage({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: '변경 이력',
|
||||
children: (
|
||||
<div className="token-setting-management-page__section-scroll">
|
||||
{isCreating ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="신규 등록 전에는 변경 이력이 없습니다."
|
||||
description="설정을 먼저 저장하면 이후 수정/삭제 이력과 IP 기록이 여기에 쌓입니다."
|
||||
/>
|
||||
) : isActivityLoading ? (
|
||||
<Paragraph>불러오는 중...</Paragraph>
|
||||
) : activities.length > 0 ? (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
columns={activityColumns}
|
||||
dataSource={activities}
|
||||
pagination={{ pageSize: 8, hideOnSinglePage: true }}
|
||||
scroll={{ x: 760 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="기록된 변경 이력이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ type PendingChatRequest = {
|
||||
};
|
||||
|
||||
type PendingContextConfirm = {
|
||||
requestId?: string;
|
||||
sessionId: string;
|
||||
mode: 'queue' | 'direct';
|
||||
text: string;
|
||||
@@ -374,6 +375,7 @@ export function useConversationComposerController({
|
||||
const executeSendMessage = useCallback(
|
||||
async (request: PendingContextConfirm) => {
|
||||
const {
|
||||
requestId: requestedRequestId,
|
||||
sessionId,
|
||||
mode,
|
||||
text,
|
||||
@@ -490,7 +492,7 @@ export function useConversationComposerController({
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestId = createClientRequestId();
|
||||
const requestId = requestedRequestId?.trim() || createClientRequestId();
|
||||
const outgoingRequest: PendingChatRequest = {
|
||||
sessionId: targetSessionId,
|
||||
requestId,
|
||||
@@ -535,6 +537,7 @@ export function useConversationComposerController({
|
||||
chatTypeLabel: requestChatTypeLabel,
|
||||
requestOrigin: origin ?? 'composer',
|
||||
parentRequestId: parentRequestId?.trim() || null,
|
||||
promptContextRef: promptContextRef ?? null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 등록',
|
||||
userMessageId: optimisticUserMessage.id,
|
||||
@@ -582,6 +585,7 @@ export function useConversationComposerController({
|
||||
chatTypeLabel: requestChatTypeLabel,
|
||||
requestOrigin: origin ?? 'composer',
|
||||
parentRequestId: parentRequestId?.trim() || null,
|
||||
promptContextRef: promptContextRef ?? null,
|
||||
status: 'accepted',
|
||||
statusMessage: '요청을 접수했습니다.',
|
||||
userMessageId: optimisticUserMessage.id,
|
||||
|
||||
@@ -36,6 +36,7 @@ function mergeConversationRequests(
|
||||
...item,
|
||||
requestOrigin: item.requestOrigin ?? previousItem.requestOrigin ?? null,
|
||||
parentRequestId: item.parentRequestId?.trim() || previousItem.parentRequestId?.trim() || null,
|
||||
promptContextRef: item.promptContextRef ?? previousItem.promptContextRef ?? null,
|
||||
statusMessage: nextStatusMessage,
|
||||
userMessageId: item.userMessageId ?? previousItem.userMessageId,
|
||||
userText: nextUserText,
|
||||
|
||||
@@ -14,7 +14,6 @@ type UseConversationViewControllerOptions = {
|
||||
activeView: 'chat' | 'runtime' | 'errors';
|
||||
isMobileViewport: boolean;
|
||||
previewItems: PreviewItem[];
|
||||
selectedChatTypeId: string | null;
|
||||
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
|
||||
setActiveSystemStatus: (value: string | null) => void;
|
||||
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
|
||||
@@ -30,7 +29,6 @@ export function useConversationViewController({
|
||||
composerRef,
|
||||
isMobileViewport,
|
||||
previewItems,
|
||||
selectedChatTypeId,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
@@ -39,6 +37,8 @@ export function useConversationViewController({
|
||||
setIsSystemStatusPending,
|
||||
}: UseConversationViewControllerOptions) {
|
||||
const previousSessionIdRef = useRef(activeSessionId);
|
||||
const previousActiveViewRef = useRef(activeView);
|
||||
const hasInitializedComposerFocusRef = useRef(false);
|
||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||
const [activePreviewOverride, setActivePreviewOverride] = useState<PreviewItem | null>(null);
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
@@ -158,12 +158,22 @@ export function useConversationViewController({
|
||||
}, [activePreview, isPreviewModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousActiveView = previousActiveViewRef.current;
|
||||
previousActiveViewRef.current = activeView;
|
||||
|
||||
if (activeView !== 'chat' || isMobileViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldFocusComposer = !hasInitializedComposerFocusRef.current || previousActiveView !== 'chat';
|
||||
hasInitializedComposerFocusRef.current = true;
|
||||
|
||||
if (!shouldFocusComposer) {
|
||||
return;
|
||||
}
|
||||
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
}, [activeView, composerRef, isMobileViewport, selectedChatTypeId]);
|
||||
}, [activeView, composerRef, isMobileViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'chat') {
|
||||
|
||||
@@ -7,9 +7,6 @@ import type {
|
||||
|
||||
const SCROLL_JUMP_HIDE_THRESHOLD = 24;
|
||||
const SCROLL_JUMP_MIN_OVERFLOW = 48;
|
||||
const SCROLL_JUMP_DIRECTION_THRESHOLD = 6;
|
||||
const SCROLL_JUMP_IDLE_HIDE_DELAY_MS = 900;
|
||||
|
||||
type UseConversationViewportControllerOptions = {
|
||||
activeConversation: ChatConversationSummary | null;
|
||||
activeQueuedComposerRequestsCount: number;
|
||||
@@ -55,7 +52,6 @@ export function useConversationViewportController({
|
||||
const systemStatusTimerRef = useRef<number | null>(null);
|
||||
const restoreAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const showScrollToBottomRef = useRef(false);
|
||||
const scrollJumpIdleTimerRef = useRef<number | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const lastViewportScrollTopRef = useRef(0);
|
||||
const autoScrollSuspendedUntilRef = useRef(0);
|
||||
@@ -80,13 +76,6 @@ export function useConversationViewportController({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearScrollJumpIdleTimer = useCallback(() => {
|
||||
if (scrollJumpIdleTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollJumpIdleTimerRef.current);
|
||||
scrollJumpIdleTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncShowScrollToBottom = useCallback((nextValue: boolean) => {
|
||||
if (showScrollToBottomRef.current === nextValue) {
|
||||
return;
|
||||
@@ -174,22 +163,12 @@ export function useConversationViewportController({
|
||||
lastViewportScrollTopRef.current = currentScrollTop;
|
||||
|
||||
if (maxScrollDistance < SCROLL_JUMP_MIN_OVERFLOW || shouldStickToBottom) {
|
||||
clearScrollJumpIdleTimer();
|
||||
syncShowScrollToBottom(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(scrollDelta) >= SCROLL_JUMP_DIRECTION_THRESHOLD) {
|
||||
clearScrollJumpIdleTimer();
|
||||
scrollJumpIdleTimerRef.current = window.setTimeout(() => {
|
||||
scrollJumpIdleTimerRef.current = null;
|
||||
syncShowScrollToBottom(false);
|
||||
}, SCROLL_JUMP_IDLE_HIDE_DELAY_MS);
|
||||
}
|
||||
|
||||
syncShowScrollToBottom(true);
|
||||
}, [
|
||||
clearScrollJumpIdleTimer,
|
||||
isAutoScrollSuspended,
|
||||
releaseAutoScrollSuspension,
|
||||
syncShowScrollToBottom,
|
||||
@@ -528,13 +507,12 @@ export function useConversationViewportController({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSystemStatusTimer();
|
||||
clearScrollJumpIdleTimer();
|
||||
|
||||
if (restoreAutoScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [clearScrollJumpIdleTimer, clearSystemStatusTimer]);
|
||||
}, [clearSystemStatusTimer]);
|
||||
|
||||
return {
|
||||
activeSystemStatus,
|
||||
|
||||
@@ -617,6 +617,10 @@ function resolvePromptSubmissionParentRequestId(
|
||||
return resolveChildComposerParentRequestId(request, requestStateMap) || normalizedRequestId;
|
||||
}
|
||||
|
||||
function isPromptFollowupRequest(request: ChatConversationRequest) {
|
||||
return request.requestOrigin === 'prompt';
|
||||
}
|
||||
|
||||
function resolveConversationMessageGroupRequestId(
|
||||
requestId: string | null | undefined,
|
||||
requestStateMap: Map<string, ChatConversationRequest>,
|
||||
@@ -1155,6 +1159,10 @@ function collapseDuplicatedLeadingExecutorHeaders(text: string) {
|
||||
return collapsedLines.join('\n');
|
||||
}
|
||||
|
||||
function isRequestInFlightStatus(status: ChatConversationRequestStatus | null | undefined) {
|
||||
return status === 'accepted' || status === 'queued' || status === 'started';
|
||||
}
|
||||
|
||||
function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload {
|
||||
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
|
||||
const attachmentExtraction = extractAttachmentPreviewUrls(message.text);
|
||||
@@ -1752,6 +1760,9 @@ function formatRequestStatusLabel(
|
||||
},
|
||||
) {
|
||||
const hideFinalizedLabel = options?.hideFinalizedLabel === true;
|
||||
const retryCount = Math.max(0, Number(request?.retryCount ?? 0) || 0);
|
||||
const appendRetryLabel = (label: string | null) =>
|
||||
label && retryCount > 0 ? `${label} · 재처리 ${retryCount}회` : label;
|
||||
|
||||
if (hasAnsweredRequest(request)) {
|
||||
if (request?.status === "completed") {
|
||||
@@ -1759,27 +1770,29 @@ function formatRequestStatusLabel(
|
||||
return null;
|
||||
}
|
||||
|
||||
return attentionState?.hasPendingPromptBadge || attentionState?.hasPendingVerificationBadge ? "확인대기" : "완료";
|
||||
return appendRetryLabel(
|
||||
attentionState?.hasPendingPromptBadge || attentionState?.hasPendingVerificationBadge ? "확인대기" : "완료",
|
||||
);
|
||||
}
|
||||
|
||||
return hideFinalizedLabel ? null : "답변도착";
|
||||
return appendRetryLabel(hideFinalizedLabel ? null : "답변도착");
|
||||
}
|
||||
|
||||
switch (request?.status) {
|
||||
case 'accepted':
|
||||
return '접수됨';
|
||||
return appendRetryLabel('접수됨');
|
||||
case 'queued':
|
||||
return '대기중';
|
||||
return appendRetryLabel('대기중');
|
||||
case 'started':
|
||||
return request.hasResponse ? '응답작성중' : '처리중';
|
||||
return appendRetryLabel(request.hasResponse ? '응답작성중' : '처리중');
|
||||
case 'completed':
|
||||
return '완료';
|
||||
return appendRetryLabel('완료');
|
||||
case 'failed':
|
||||
return '실패';
|
||||
return appendRetryLabel('실패');
|
||||
case 'cancelled':
|
||||
return '취소됨';
|
||||
return appendRetryLabel('취소됨');
|
||||
case 'removed':
|
||||
return '삭제됨';
|
||||
return appendRetryLabel('삭제됨');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -2144,6 +2157,20 @@ function getRequestDetailBadge(request: ChatConversationRequest | undefined): Sy
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRequestRetryBadge(request: ChatConversationRequest | undefined): SystemExecutionBadge | null {
|
||||
const retryCount = Math.max(0, Number(request?.retryCount ?? 0) || 0);
|
||||
|
||||
if (retryCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: `재처리 ${retryCount}회`,
|
||||
shortLabel: `재처리 ${retryCount}`,
|
||||
tone: 'processing',
|
||||
};
|
||||
}
|
||||
|
||||
function buildChecklistStageBadge(lines: string[], request?: ChatConversationRequest): SystemExecutionBadge | null {
|
||||
const entries = buildChatActivityChecklistEntries(lines, request);
|
||||
const activeEntry =
|
||||
@@ -2186,6 +2213,7 @@ function buildPromptStateBadge(options: {
|
||||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||||
submittedCount: number;
|
||||
isManuallyCompleted?: boolean;
|
||||
isPromptManuallyCompleted?: boolean;
|
||||
}): SystemExecutionBadge | null {
|
||||
const { promptTargets, submittedCount, isManuallyCompleted } = options;
|
||||
|
||||
@@ -2254,6 +2282,7 @@ function buildVerificationStateBadge(options: {
|
||||
hasVerificationTarget: boolean;
|
||||
hasConfirmedVerificationTarget: boolean;
|
||||
isManuallyCompleted?: boolean;
|
||||
isPromptManuallyCompleted?: boolean;
|
||||
}): SystemExecutionBadge | null {
|
||||
const {
|
||||
request,
|
||||
@@ -2262,23 +2291,32 @@ function buildVerificationStateBadge(options: {
|
||||
hasVerificationTarget,
|
||||
hasConfirmedVerificationTarget,
|
||||
isManuallyCompleted,
|
||||
isPromptManuallyCompleted,
|
||||
} = options;
|
||||
|
||||
if (!request || promptTargets.length > 0 || !hasVerificationTarget) {
|
||||
if (!request || isPromptManuallyCompleted || promptTargets.length > 0 || !hasVerificationTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const verificationText = [request.userText, request.responseText, ...activityLines].join('\n');
|
||||
const usesVerificationLabel = VERIFICATION_REQUEST_PATTERN.test(verificationText);
|
||||
const completedLabel = usesVerificationLabel ? '검증 확인' : '응답 확인';
|
||||
const pendingLabel = usesVerificationLabel ? '검증 미확인' : '응답 미확인';
|
||||
|
||||
if (!usesVerificationLabel) {
|
||||
if (!request.hasResponse && request.status !== 'completed') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return isManuallyCompleted
|
||||
? {
|
||||
label: usesVerificationLabel ? '검증 확인' : '응답 확인',
|
||||
label: completedLabel,
|
||||
shortLabel: '확인',
|
||||
tone: 'completed',
|
||||
}
|
||||
: {
|
||||
label: usesVerificationLabel ? '검증 미확인' : '응답 미확인',
|
||||
label: pendingLabel,
|
||||
shortLabel: '미확인',
|
||||
tone: 'attention',
|
||||
};
|
||||
@@ -2291,6 +2329,7 @@ function hasPendingVerificationState(options: {
|
||||
hasVerificationTarget: boolean;
|
||||
hasConfirmedVerificationTarget: boolean;
|
||||
isManuallyCompleted?: boolean;
|
||||
isPromptManuallyCompleted?: boolean;
|
||||
}) {
|
||||
return buildVerificationStateBadge(options)?.tone === 'attention';
|
||||
}
|
||||
@@ -3304,14 +3343,32 @@ const ChatComposerInput = memo(function ChatComposerInput({
|
||||
|
||||
function SharedRoomsRequestCard({
|
||||
request,
|
||||
attentionState,
|
||||
canCompletePrompt = false,
|
||||
canCompleteVerification = false,
|
||||
canReplyToResponse = false,
|
||||
isManualCompletionSaving = false,
|
||||
isReplyReferenceActive = false,
|
||||
onCompletePrompt,
|
||||
onCompleteVerification,
|
||||
onReplyToResponse,
|
||||
onSelect,
|
||||
}: {
|
||||
request: ChatConversationRequest;
|
||||
attentionState?: SystemExecutionAttentionState;
|
||||
canCompletePrompt?: boolean;
|
||||
canCompleteVerification?: boolean;
|
||||
canReplyToResponse?: boolean;
|
||||
isManualCompletionSaving?: boolean;
|
||||
isReplyReferenceActive?: boolean;
|
||||
onCompletePrompt?: (() => void) | null;
|
||||
onCompleteVerification?: (() => void) | null;
|
||||
onReplyToResponse?: (() => void) | null;
|
||||
onSelect?: (() => void) | null;
|
||||
}) {
|
||||
const questionText = (request.userText ?? "").trim() || "-";
|
||||
const answerText = (request.responseText ?? "").trim() || request.statusMessage?.trim() || "아직 답변이 없습니다.";
|
||||
const requestStatusLabel = formatRequestStatusLabel(request);
|
||||
const requestStatusLabel = formatRequestStatusLabel(request, attentionState);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -3354,6 +3411,58 @@ function SharedRoomsRequestCard({
|
||||
<div className="app-chat-bubble__content">{answerText}</div>
|
||||
</div>
|
||||
</div>
|
||||
{canCompletePrompt || canCompleteVerification || canReplyToResponse ? (
|
||||
<div className="app-chat-message__response-actions">
|
||||
{canCompletePrompt ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="app-chat-message__response-action"
|
||||
icon={<CheckOutlined />}
|
||||
loading={isManualCompletionSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCompletePrompt?.();
|
||||
}}
|
||||
>
|
||||
완료 처리
|
||||
</Button>
|
||||
) : null}
|
||||
{canCompleteVerification ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="app-chat-message__response-action"
|
||||
icon={<CheckOutlined />}
|
||||
loading={isManualCompletionSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCompleteVerification?.();
|
||||
}}
|
||||
>
|
||||
완료 처리
|
||||
</Button>
|
||||
) : null}
|
||||
{canReplyToResponse ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={[
|
||||
'app-chat-message__response-action',
|
||||
'app-chat-message__response-reply-button',
|
||||
isReplyReferenceActive ? 'app-chat-message__response-reply-button--active' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
icon={<SendOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onReplyToResponse?.();
|
||||
}}
|
||||
>
|
||||
{isReplyReferenceActive ? '답변 참조 중' : '답변하기'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3480,9 +3589,7 @@ export function ChatConversationView({
|
||||
const [systemExecutionDisplayMode, setSystemExecutionDisplayMode] = useState<SystemExecutionDisplayMode>('collapsed');
|
||||
const [systemExecutionFilter, setSystemExecutionFilter] = useState<SystemExecutionFilter>('active-attention');
|
||||
const [systemExecutionSort, setSystemExecutionSort] = useState<SystemExecutionSort>('latest');
|
||||
const [roomShareExpandMode, setRoomShareExpandMode] = useState<RoomShareExpandMode>(
|
||||
useRoomsShareBubbleFlow ? 'latest' : 'pending',
|
||||
);
|
||||
const [roomShareExpandMode, setRoomShareExpandMode] = useState<RoomShareExpandMode>('pending');
|
||||
const [selectedRoomShareGroupId, setSelectedRoomShareGroupId] = useState<string | null>(null);
|
||||
const [replyReferenceRequestId, setReplyReferenceRequestId] = useState('');
|
||||
const [composerForceDraftSyncVersion, setComposerForceDraftSyncVersion] = useState(0);
|
||||
@@ -3702,7 +3809,7 @@ export function ChatConversationView({
|
||||
return [...ordered, ...orphanActivityMessages];
|
||||
}, [requestStateMap, visibleMessages]);
|
||||
useEffect(() => {
|
||||
setRoomShareExpandMode(useRoomsShareBubbleFlow ? 'latest' : 'pending');
|
||||
setRoomShareExpandMode('pending');
|
||||
}, [useRoomsShareBubbleFlow]);
|
||||
|
||||
const useSharedRoomsSimplifiedView = showRoomsShareHeader && useSharedComposerChrome && !roomsShareUseSharedPageNav;
|
||||
@@ -4104,7 +4211,8 @@ export function ChatConversationView({
|
||||
() =>
|
||||
visibleSystemExecutionRequests.filter(
|
||||
(request) =>
|
||||
request.status === 'accepted' || request.status === 'queued' || request.status === 'started',
|
||||
!isPromptFollowupRequest(request)
|
||||
&& (request.status === 'accepted' || request.status === 'queued' || request.status === 'started'),
|
||||
),
|
||||
[visibleSystemExecutionRequests],
|
||||
);
|
||||
@@ -4161,6 +4269,22 @@ export function ChatConversationView({
|
||||
const nextMap = new Map<string, SystemExecutionAttentionState>();
|
||||
|
||||
visibleSystemExecutionRequests.forEach((request) => {
|
||||
if (isPromptFollowupRequest(request)) {
|
||||
nextMap.set(request.requestId, {
|
||||
activityLines: [],
|
||||
promptTargets: [],
|
||||
promptSubmittedCount: 0,
|
||||
isPromptManuallyCompleted: true,
|
||||
hasVerificationTarget: false,
|
||||
hasConfirmedVerificationTarget: true,
|
||||
isVerificationManuallyCompleted: true,
|
||||
hasPendingPromptBadge: false,
|
||||
hasPendingVerificationBadge: false,
|
||||
hasOwnAttentionState: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activityLines = activityLinesByRequestId.get(request.requestId) ?? [];
|
||||
const promptTargets = promptTargetsByRequestId.get(request.requestId) ?? [];
|
||||
const promptSubmittedCount =
|
||||
@@ -4193,6 +4317,7 @@ export function ChatConversationView({
|
||||
promptTargets,
|
||||
hasVerificationTarget,
|
||||
hasConfirmedVerificationTarget,
|
||||
isPromptManuallyCompleted,
|
||||
});
|
||||
const hasOwnAttentionState =
|
||||
hasPendingPromptBadge || hasPendingVerificationBadge || isDisconnectedRequestNeedingAttention(request);
|
||||
@@ -4219,6 +4344,7 @@ export function ChatConversationView({
|
||||
lastReadResponseMessageId,
|
||||
openedPreviewArtifactRequestIdSet,
|
||||
openedPreviewRequestIds,
|
||||
childRequestIdsByParentRequestId,
|
||||
localSubmittedPromptCountByRequestId,
|
||||
promptFollowupCountByParentRequestId,
|
||||
promptTargetsByRequestId,
|
||||
@@ -4233,12 +4359,18 @@ export function ChatConversationView({
|
||||
|
||||
if (systemExecutionFilter === 'active') {
|
||||
return visibleSystemExecutionRequests.filter(
|
||||
(request) => request.status === 'accepted' || request.status === 'queued' || request.status === 'started',
|
||||
(request) =>
|
||||
!isPromptFollowupRequest(request)
|
||||
&& (request.status === 'accepted' || request.status === 'queued' || request.status === 'started'),
|
||||
);
|
||||
}
|
||||
|
||||
if (systemExecutionFilter === 'active-attention') {
|
||||
return visibleSystemExecutionRequests.filter((request) => {
|
||||
if (isPromptFollowupRequest(request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.status === 'accepted' || request.status === 'queued' || request.status === 'started') {
|
||||
return true;
|
||||
}
|
||||
@@ -4248,7 +4380,9 @@ export function ChatConversationView({
|
||||
}
|
||||
|
||||
return visibleSystemExecutionRequests.filter(
|
||||
(request) => systemExecutionAttentionStateByRequestId.get(request.requestId)?.hasOwnAttentionState === true,
|
||||
(request) =>
|
||||
!isPromptFollowupRequest(request)
|
||||
&& systemExecutionAttentionStateByRequestId.get(request.requestId)?.hasOwnAttentionState === true,
|
||||
);
|
||||
},
|
||||
[systemExecutionAttentionStateByRequestId, visibleSystemExecutionRequests, systemExecutionFilter],
|
||||
@@ -4362,12 +4496,23 @@ export function ChatConversationView({
|
||||
});
|
||||
}
|
||||
|
||||
return messageEntries
|
||||
const groupRequestIdsByRootId = new Map<string, string[]>();
|
||||
|
||||
requestIdsByRootRequestId.forEach((requestIds, rootRequestId) => {
|
||||
const dedupedRequestIds = Array.from(new Set(requestIds.map((requestId) => requestId.trim()).filter(Boolean)));
|
||||
|
||||
if (dedupedRequestIds.length > 0) {
|
||||
groupRequestIdsByRootId.set(rootRequestId, dedupedRequestIds);
|
||||
}
|
||||
});
|
||||
|
||||
const groupedEntries = messageEntries
|
||||
.filter((entry): entry is Extract<ConversationMessageEntry, { kind: 'group' }> => entry.kind === 'group')
|
||||
.map((entry) => {
|
||||
const groupedRequestIds = groupRequestIdsByRootId.get(entry.groupId) ?? entry.requestIds;
|
||||
const groupedRequests = Array.from(
|
||||
new Map(
|
||||
entry.requestIds
|
||||
groupedRequestIds
|
||||
.map((requestId) => requestStateMap.get(requestId))
|
||||
.filter((item): item is ChatConversationRequest => item != null)
|
||||
.map((request) => [request.requestId, request] as const),
|
||||
@@ -4393,10 +4538,60 @@ export function ChatConversationView({
|
||||
statusSummary,
|
||||
hasAttention,
|
||||
};
|
||||
});
|
||||
const existingGroupIdSet = new Set(groupedEntries.map((entry) => entry.groupId));
|
||||
const pendingOnlyGroups = Array.from(groupRequestIdsByRootId.entries())
|
||||
.filter(([groupId]) => !existingGroupIdSet.has(groupId))
|
||||
.map(([groupId, groupedRequestIds]) => {
|
||||
const groupedRequests = Array.from(
|
||||
new Map(
|
||||
groupedRequestIds
|
||||
.map((requestId) => requestStateMap.get(requestId))
|
||||
.filter((item): item is ChatConversationRequest => item != null)
|
||||
.map((request) => [request.requestId, request] as const),
|
||||
).values(),
|
||||
);
|
||||
|
||||
if (groupedRequests.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAttention = groupedRequests.some((request) => {
|
||||
if (isRequestRunningStatus(request.status) || isRequestQueueStatus(request.status)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return systemExecutionAttentionStateByRequestId.get(request.requestId)?.hasOwnAttentionState === true;
|
||||
});
|
||||
|
||||
if (!hasAttention) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const representativeRequest = groupedRequests[groupedRequests.length - 1] ?? null;
|
||||
|
||||
return {
|
||||
groupId,
|
||||
groupedRequests,
|
||||
representativeRequest,
|
||||
statusSummary: resolveAggregatedRequestStatusSummary(groupedRequests, systemExecutionAttentionStateByRequestId),
|
||||
hasAttention,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.representativeRequest != null);
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
|
||||
|
||||
return [...groupedEntries, ...pendingOnlyGroups]
|
||||
.filter((entry) => entry.representativeRequest != null)
|
||||
.sort((left, right) => left.representativeRequest.createdAt.localeCompare(right.representativeRequest.createdAt));
|
||||
},
|
||||
[messageEntries, orderedMessages, requestStateMap, systemExecutionAttentionStateByRequestId, useSharedRoomsSimplifiedView],
|
||||
[
|
||||
messageEntries,
|
||||
orderedMessages,
|
||||
requestIdsByRootRequestId,
|
||||
requestStateMap,
|
||||
systemExecutionAttentionStateByRequestId,
|
||||
useSharedRoomsSimplifiedView,
|
||||
],
|
||||
);
|
||||
const roomShareNavigableGroups = useMemo(() => {
|
||||
if (roomShareExpandMode === 'pending') {
|
||||
@@ -4501,7 +4696,7 @@ export function ChatConversationView({
|
||||
const nowMs = Date.now();
|
||||
const processingRequests = roomShareAllRequests.filter(
|
||||
(request) => isRequestRunningStatus(request.status) || isRequestQueueStatus(request.status),
|
||||
);
|
||||
).filter((request) => !isPromptFollowupRequest(request));
|
||||
const processingTarget = processingRequests[processingRequests.length - 1] ?? null;
|
||||
const elapsedLabel = processingTarget ? formatOngoingElapsedLabel(processingTarget.createdAt, nowMs) : '';
|
||||
const processingCount = processingRequests.length;
|
||||
@@ -5344,6 +5539,7 @@ export function ChatConversationView({
|
||||
hasVerificationTarget,
|
||||
hasConfirmedVerificationTarget,
|
||||
isManuallyCompleted: isVerificationManuallyCompleted,
|
||||
isPromptManuallyCompleted,
|
||||
});
|
||||
const hasPendingVerificationBadge = attentionState?.hasPendingVerificationBadge === true;
|
||||
const manualCompletionTypes = buildManualCompletionTypes({
|
||||
@@ -5353,9 +5549,11 @@ export function ChatConversationView({
|
||||
const checklistBadge = buildChecklistStageBadge(activityLines, representativeRequest);
|
||||
const readStateBadge =
|
||||
verificationStateBadge ? null : buildReadStateBadge(representativeRequest, lastReadResponseMessageId);
|
||||
const retryBadge = getRequestRetryBadge(representativeRequest);
|
||||
const secondaryBadges = [
|
||||
hierarchyBadge,
|
||||
rootRelationshipBadge,
|
||||
retryBadge,
|
||||
detailBadge,
|
||||
promptStateBadge,
|
||||
verificationStateBadge,
|
||||
@@ -5812,6 +6010,25 @@ export function ChatConversationView({
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const activateReplyReference = (
|
||||
requestId: string | null | undefined,
|
||||
options?: {
|
||||
focusComposer?: boolean;
|
||||
},
|
||||
) => {
|
||||
const normalizedRequestId = requestId?.trim() || '';
|
||||
|
||||
if (!normalizedRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReplyReferenceRequestId(normalizedRequestId);
|
||||
|
||||
if (options?.focusComposer !== false) {
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
}
|
||||
};
|
||||
|
||||
const closeChildComposer = (groupId: string) => {
|
||||
const normalizedGroupId = groupId.trim();
|
||||
|
||||
@@ -5959,13 +6176,19 @@ export function ChatConversationView({
|
||||
hasVerificationTarget: responseHasVerificationTarget,
|
||||
hasConfirmedVerificationTarget: responseHasConfirmedVerificationTarget,
|
||||
isManuallyCompleted: isResponseVerificationManuallyCompleted,
|
||||
isPromptManuallyCompleted: isResponsePromptManuallyCompleted,
|
||||
});
|
||||
const responseHasPendingVerificationBadge = attentionState?.hasPendingVerificationBadge === true;
|
||||
const responseSecondaryBadges = [responsePromptStateBadge, responseVerificationStateBadge].filter(
|
||||
(badge): badge is SystemExecutionBadge => badge != null,
|
||||
);
|
||||
const responseRetryBadge = getRequestRetryBadge(requestState);
|
||||
const responseDisplayBadges = [
|
||||
...(responseRetryBadge ? [responseRetryBadge] : []),
|
||||
...responseSecondaryBadges,
|
||||
];
|
||||
const responsePrimaryManualCompletionType = resolvePrimaryManualCompletionType({
|
||||
secondaryBadges: responseSecondaryBadges,
|
||||
secondaryBadges: responseDisplayBadges,
|
||||
promptStateBadge: responsePromptStateBadge,
|
||||
verificationStateBadge: responseVerificationStateBadge,
|
||||
hasPendingPromptBadge: responseHasPendingPromptBadge,
|
||||
@@ -5991,9 +6214,6 @@ export function ChatConversationView({
|
||||
promptSignature: buildPromptTargetSignature(promptTargets[0]!),
|
||||
}
|
||||
: null;
|
||||
const hasChildRequest =
|
||||
Boolean(message.clientRequestId) &&
|
||||
(childRequestIdsByParentRequestId.get(message.clientRequestId ?? '')?.length ?? 0) > 0;
|
||||
const showResponseManualCompleteAction =
|
||||
enableExecutionReviewUi &&
|
||||
message.author === 'codex' &&
|
||||
@@ -6004,19 +6224,21 @@ export function ChatConversationView({
|
||||
message.author === 'codex' &&
|
||||
Boolean(message.clientRequestId) &&
|
||||
responsePromptTargets.length > 0 &&
|
||||
!isResponsePromptManuallyCompleted;
|
||||
!isResponsePromptManuallyCompleted &&
|
||||
!isRequestInFlightStatus(requestState?.status);
|
||||
const canCompleteVerificationFromResponse =
|
||||
showRoomsShareHeader &&
|
||||
message.author === 'codex' &&
|
||||
Boolean(message.clientRequestId) &&
|
||||
responsePromptTargets.length === 0 &&
|
||||
responsePrimaryManualCompletionType === 'verification' &&
|
||||
!hasChildRequest;
|
||||
!isRequestInFlightStatus(requestState?.status);
|
||||
const canReplyToResponse =
|
||||
showRoomsShareHeader &&
|
||||
message.author === 'codex' &&
|
||||
Boolean(message.clientRequestId) &&
|
||||
responsePromptTargets.length === 0;
|
||||
responsePromptTargets.length === 0 &&
|
||||
!isRequestInFlightStatus(requestState?.status);
|
||||
const isReplyReferenceActive =
|
||||
canReplyToResponse &&
|
||||
replyReferenceRequestId.trim() === (message.clientRequestId?.trim() ?? '');
|
||||
@@ -6247,8 +6469,9 @@ export function ChatConversationView({
|
||||
className={['app-chat-message__response-action', 'app-chat-message__response-reply-button', isReplyReferenceActive ? 'app-chat-message__response-reply-button--active' : ''].filter(Boolean).join(' ')}
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => {
|
||||
setReplyReferenceRequestId(message.clientRequestId?.trim() ?? '');
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
activateReplyReference(message.clientRequestId, {
|
||||
focusComposer: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isReplyReferenceActive ? '답변 참조 중' : '답변하기'}
|
||||
@@ -6997,6 +7220,44 @@ export function ChatConversationView({
|
||||
const visibleMessages = Array.isArray(entry.messages) ? entry.messages.filter((message) => !isActivityLogMessage(message)) : [];
|
||||
const collapsedVisibleFinalMessage =
|
||||
visibleMessages.length > 0 ? visibleMessages[visibleMessages.length - 1] : null;
|
||||
const representativeAttentionState = representativeRequest
|
||||
? systemExecutionAttentionStateByRequestId.get(representativeRequest.requestId)
|
||||
: undefined;
|
||||
const representativePromptTargets = representativeAttentionState?.promptTargets ?? [];
|
||||
const isRepresentativePromptManuallyCompleted =
|
||||
representativeAttentionState?.isPromptManuallyCompleted === true;
|
||||
const representativeHasPendingPromptBadge =
|
||||
representativeAttentionState?.hasPendingPromptBadge === true;
|
||||
const representativeHasPendingVerificationBadge =
|
||||
representativeAttentionState?.hasPendingVerificationBadge === true;
|
||||
const representativeManualCompletionTypes = buildManualCompletionTypes({
|
||||
hasPendingPromptBadge: representativeHasPendingPromptBadge,
|
||||
hasPendingVerificationBadge: representativeHasPendingVerificationBadge,
|
||||
});
|
||||
const isRepresentativeManualCompletionSaving =
|
||||
representativeRequest != null &&
|
||||
representativeManualCompletionTypes.some((type) =>
|
||||
pendingManualCompletionActionKeySet.has(`${type}:${representativeRequest.requestId}`),
|
||||
);
|
||||
const canCompletePromptFromCard =
|
||||
representativeRequest != null &&
|
||||
representativePromptTargets.length > 0 &&
|
||||
!isRepresentativePromptManuallyCompleted &&
|
||||
!isRequestInFlightStatus(representativeRequest.status);
|
||||
const canCompleteVerificationFromCard =
|
||||
representativeRequest != null &&
|
||||
representativePromptTargets.length === 0 &&
|
||||
representativeHasPendingVerificationBadge &&
|
||||
!isRequestInFlightStatus(representativeRequest.status);
|
||||
const canReplyToCardResponse =
|
||||
representativeRequest != null &&
|
||||
representativeRequest.hasResponse &&
|
||||
representativePromptTargets.length === 0 &&
|
||||
!isRequestInFlightStatus(representativeRequest.status);
|
||||
const isCardReplyReferenceActive =
|
||||
representativeRequest != null &&
|
||||
canReplyToCardResponse &&
|
||||
replyReferenceRequestId.trim() === representativeRequest.requestId.trim();
|
||||
|
||||
if (!representativeRequest) {
|
||||
return null;
|
||||
@@ -7011,6 +7272,29 @@ export function ChatConversationView({
|
||||
<SharedRoomsRequestCard
|
||||
key={entry.groupId}
|
||||
request={representativeRequest}
|
||||
attentionState={representativeAttentionState}
|
||||
canCompletePrompt={canCompletePromptFromCard}
|
||||
canCompleteVerification={canCompleteVerificationFromCard}
|
||||
canReplyToResponse={canReplyToCardResponse}
|
||||
isManualCompletionSaving={isRepresentativeManualCompletionSaving}
|
||||
isReplyReferenceActive={isCardReplyReferenceActive}
|
||||
onCompletePrompt={() => {
|
||||
void completeManualBadge({
|
||||
requestId: representativeRequest.requestId,
|
||||
type: 'prompt',
|
||||
});
|
||||
}}
|
||||
onCompleteVerification={() => {
|
||||
void completeManualBadge({
|
||||
requestId: representativeRequest.requestId,
|
||||
type: 'verification',
|
||||
});
|
||||
}}
|
||||
onReplyToResponse={() => {
|
||||
activateReplyReference(representativeRequest.requestId, {
|
||||
focusComposer: false,
|
||||
});
|
||||
}}
|
||||
onSelect={() => {
|
||||
setSelectedRoomShareGroupId(entry.groupId);
|
||||
scrollToRoomShareGroup(entry.groupId);
|
||||
@@ -7113,9 +7397,7 @@ export function ChatConversationView({
|
||||
const parentRequestId = entry.request?.parentRequestId?.trim() || '';
|
||||
const parentRequest = parentRequestId ? requestStateMap.get(parentRequestId) : null;
|
||||
const groupRelationText = parentRequest
|
||||
? `${
|
||||
entry.request?.requestOrigin === 'prompt' ? '상위 질의' : '상위 요청 연결'
|
||||
}: ${summarizeQueuedText(parentRequest.userText || parentRequest.responseText || parentRequestId, 52)}`
|
||||
? `부모 요청: ${summarizeQueuedText(parentRequest.userText || parentRequest.responseText || parentRequestId, 52)}`
|
||||
: null;
|
||||
const childComposerGroupId = entry.groupId;
|
||||
const childComposerParentRequestId = resolveChildComposerParentRequestId(entry.request, requestStateMap);
|
||||
|
||||
@@ -1194,9 +1194,13 @@ export function ChatPromptCard({
|
||||
? '시간 초과 자동선택'
|
||||
: target.resolvedBy === 'system'
|
||||
? '임의 선택 결과'
|
||||
: isResolved && resolvedSelectedValues.length > 0
|
||||
? '선택 완료'
|
||||
: null;
|
||||
: target.resolvedBy === 'user'
|
||||
? resolvedSelectedValues.length > 0
|
||||
? '선택 완료'
|
||||
: '전달 완료'
|
||||
: isResolved && resolvedSelectedValues.length > 0
|
||||
? '선택 완료'
|
||||
: null;
|
||||
const expandedOption = steps
|
||||
.flatMap((step) => step.options)
|
||||
.find((option) => option.value === expandedOptionValue) ?? null;
|
||||
@@ -1808,13 +1812,17 @@ export function ChatPromptCard({
|
||||
? target.resolvedBy === 'timeout'
|
||||
? `시간 초과로 자동 선택: ${resolvedSelectionSummary}`
|
||||
: `선택 결과: ${resolvedSelectionSummary}`
|
||||
: readOnly || target.readOnly === true
|
||||
? '지난 prompt는 읽기 전용입니다.'
|
||||
: hasStepper
|
||||
? progressPayload?.summaryText || '단계를 순서대로 선택하세요.'
|
||||
: selectionSummary
|
||||
? `선택: ${selectionSummary}`
|
||||
: '항목을 선택하세요.'}
|
||||
: isResolved && target.resolvedBy === 'user'
|
||||
? target.resultText?.trim()
|
||||
? `전달 결과: ${target.resultText.trim()}`
|
||||
: '선택 없이 전달 완료됨.'
|
||||
: readOnly || target.readOnly === true
|
||||
? '지난 prompt는 읽기 전용입니다.'
|
||||
: hasStepper
|
||||
? progressPayload?.summaryText || '단계를 순서대로 선택하세요.'
|
||||
: selectionSummary
|
||||
? `선택: ${selectionSummary}`
|
||||
: '항목을 선택하세요.'}
|
||||
{submittedFreeTextValue ? (
|
||||
<span className="app-chat-prompt-card__summary-detail">추가 요청: {submittedFreeTextValue}</span>
|
||||
) : null}
|
||||
|
||||
@@ -29,6 +29,7 @@ const CHAT_SESSION_ID_KEY = 'main-chat-panel:session-id';
|
||||
const CHAT_LAST_EVENT_ID_STORAGE_PREFIX = 'main-chat-panel:last-event-id:';
|
||||
const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
|
||||
const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
|
||||
const CHAT_SHARE_ACCESS_PIN_STORAGE_KEY = 'main-chat-panel:share-access-pins';
|
||||
const CHAT_INTRO_MESSAGE =
|
||||
'요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요. 여러 Codex를 추가한 즉시 실행은 병렬로 처리됩니다.';
|
||||
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||||
@@ -69,6 +70,83 @@ function normalizeOptionalText(value: string | null | undefined) {
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function readStoredChatShareAccessPins() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return {} as Record<string, { pin: string; expiresAtMs: number | null }>;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(CHAT_SHARE_ACCESS_PIN_STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return {} as Record<string, { pin: string; expiresAtMs: number | null }>;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawValue) as Record<string, { pin?: unknown; expiresAtMs?: unknown }>;
|
||||
const nowMs = Date.now();
|
||||
const nextEntries = Object.entries(parsed).flatMap(([token, value]) => {
|
||||
const normalizedToken = normalizeRequiredText(token);
|
||||
const normalizedPin = normalizeRequiredText(typeof value?.pin === 'string' ? value.pin : '');
|
||||
const expiresAtMs = Number.isFinite(value?.expiresAtMs) ? Number(value.expiresAtMs) : null;
|
||||
|
||||
if (!normalizedToken || !normalizedPin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (expiresAtMs != null && expiresAtMs <= nowMs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[normalizedToken, { pin: normalizedPin, expiresAtMs }] as const];
|
||||
});
|
||||
|
||||
return Object.fromEntries(nextEntries);
|
||||
} catch {
|
||||
return {} as Record<string, { pin: string; expiresAtMs: number | null }>;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredChatShareAccessPins(entries: Record<string, { pin: string; expiresAtMs: number | null }>) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedEntries = Object.entries(entries).flatMap(([token, value]) => {
|
||||
const normalizedToken = normalizeRequiredText(token);
|
||||
const normalizedPin = normalizeRequiredText(value?.pin);
|
||||
const expiresAtMs = Number.isFinite(value?.expiresAtMs) ? Number(value.expiresAtMs) : null;
|
||||
|
||||
if (!normalizedToken || !normalizedPin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[normalizedToken, { pin: normalizedPin, expiresAtMs }] as const];
|
||||
});
|
||||
|
||||
if (normalizedEntries.length === 0) {
|
||||
window.localStorage.removeItem(CHAT_SHARE_ACCESS_PIN_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
CHAT_SHARE_ACCESS_PIN_STORAGE_KEY,
|
||||
JSON.stringify(Object.fromEntries(normalizedEntries)),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage failures in restricted runtimes.
|
||||
}
|
||||
}
|
||||
|
||||
function removeStoredChatShareAccessPin(token: string) {
|
||||
const nextEntries = readStoredChatShareAccessPins();
|
||||
delete nextEntries[token];
|
||||
writeStoredChatShareAccessPins(nextEntries);
|
||||
}
|
||||
|
||||
export function getStoredChatShareAccessPin(token?: string | null) {
|
||||
const normalizedToken = normalizeRequiredText(token);
|
||||
|
||||
@@ -79,11 +157,19 @@ export function getStoredChatShareAccessPin(token?: string | null) {
|
||||
const stored = chatShareAccessPinMemory.get(normalizedToken);
|
||||
|
||||
if (!stored) {
|
||||
return '';
|
||||
const persisted = readStoredChatShareAccessPins()[normalizedToken];
|
||||
|
||||
if (!persisted) {
|
||||
return '';
|
||||
}
|
||||
|
||||
chatShareAccessPinMemory.set(normalizedToken, persisted);
|
||||
return persisted.pin.trim();
|
||||
}
|
||||
|
||||
if (stored.expiresAtMs != null && stored.expiresAtMs <= Date.now()) {
|
||||
chatShareAccessPinMemory.delete(normalizedToken);
|
||||
removeStoredChatShareAccessPin(normalizedToken);
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -100,11 +186,19 @@ export function getStoredChatShareAccessPinExpiryMs(token?: string | null) {
|
||||
const stored = chatShareAccessPinMemory.get(normalizedToken);
|
||||
|
||||
if (!stored) {
|
||||
return null;
|
||||
const persisted = readStoredChatShareAccessPins()[normalizedToken];
|
||||
|
||||
if (!persisted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
chatShareAccessPinMemory.set(normalizedToken, persisted);
|
||||
return persisted.expiresAtMs;
|
||||
}
|
||||
|
||||
if (stored.expiresAtMs != null && stored.expiresAtMs <= Date.now()) {
|
||||
chatShareAccessPinMemory.delete(normalizedToken);
|
||||
removeStoredChatShareAccessPin(normalizedToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,18 +225,23 @@ export function setStoredChatShareAccessPin(
|
||||
const expiresAt = normalizeOptionalText(options?.expiresAt);
|
||||
const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN;
|
||||
const ttlMinutes = Number.isFinite(options?.ttlMinutes) ? Math.max(0, Number(options?.ttlMinutes)) : 0;
|
||||
chatShareAccessPinMemory.set(normalizedToken, {
|
||||
const nextEntry = {
|
||||
pin: normalizedPin,
|
||||
expiresAtMs: Number.isFinite(expiresAtMs)
|
||||
? expiresAtMs
|
||||
: ttlMinutes > 0
|
||||
? Date.now() + ttlMinutes * 60 * 1000
|
||||
: null,
|
||||
});
|
||||
};
|
||||
chatShareAccessPinMemory.set(normalizedToken, nextEntry);
|
||||
const nextEntries = readStoredChatShareAccessPins();
|
||||
nextEntries[normalizedToken] = nextEntry;
|
||||
writeStoredChatShareAccessPins(nextEntries);
|
||||
return;
|
||||
}
|
||||
|
||||
chatShareAccessPinMemory.delete(normalizedToken);
|
||||
removeStoredChatShareAccessPin(normalizedToken);
|
||||
}
|
||||
|
||||
function extractChatShareTokenFromPath(path: string) {
|
||||
@@ -433,7 +532,7 @@ function mergeConversationSummaries(
|
||||
roomScope: preferred.roomScope ?? fallback.roomScope ?? null,
|
||||
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
|
||||
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
|
||||
hasPendingAttention: preferred.hasPendingAttention === true || fallback.hasPendingAttention === true,
|
||||
hasPendingAttention: preferred.hasPendingAttention === true,
|
||||
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
|
||||
currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus,
|
||||
currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null,
|
||||
@@ -572,6 +671,15 @@ function normalizeChatConversationRequest(item: ChatConversationRequest): ChatCo
|
||||
totalTokens: Math.max(0, Math.round(Number(item.usageSnapshot.totalTokens ?? 0) || 0)),
|
||||
}
|
||||
: null;
|
||||
const promptContextRef =
|
||||
item.promptContextRef?.key === 'prompt_parent_question' && normalizeRequiredText(item.promptContextRef.promptTitle)
|
||||
? {
|
||||
key: 'prompt_parent_question' as const,
|
||||
promptTitle: normalizeRequiredText(item.promptContextRef.promptTitle),
|
||||
promptDescription: normalizeOptionalText(item.promptContextRef.promptDescription),
|
||||
parentQuestionText: normalizeOptionalText(item.promptContextRef.parentQuestionText),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
@@ -581,7 +689,9 @@ function normalizeChatConversationRequest(item: ChatConversationRequest): ChatCo
|
||||
chatTypeId: normalizeOptionalText(item.chatTypeId),
|
||||
chatTypeLabel: normalizeRequiredText(item.chatTypeLabel),
|
||||
parentRequestId: normalizeOptionalText(item.parentRequestId),
|
||||
promptContextRef,
|
||||
statusMessage: normalizeOptionalText(item.statusMessage),
|
||||
retryCount: Number.isFinite(Number(item.retryCount)) ? Math.max(0, Math.round(Number(item.retryCount))) : 0,
|
||||
userText: normalizeRequiredText(item.userText),
|
||||
responseText: normalizeRequiredText(item.responseText),
|
||||
usageSnapshot,
|
||||
@@ -1519,13 +1629,13 @@ async function requestChatApi<T>(
|
||||
sharePin?: string | null;
|
||||
},
|
||||
): Promise<T> {
|
||||
const allowUnauthenticated = options?.allowUnauthenticated === true;
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const accessToken = getRegisteredAccessToken();
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Number.isFinite(options?.timeoutMs) ? Math.max(1000, Number(options?.timeoutMs)) : 8000;
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
const allowUnauthenticated = options?.allowUnauthenticated === true;
|
||||
|
||||
if (!allowUnauthenticated && !hasRegisteredAccessTokenAccess()) {
|
||||
window.clearTimeout(timeoutId);
|
||||
@@ -2311,6 +2421,11 @@ export type ChatShareSnapshot = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
requestBadgeLabel?: string | null;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
contextLabel?: string | null;
|
||||
contextDescription?: string | null;
|
||||
notifyOffline?: boolean;
|
||||
};
|
||||
rootRequestId: string;
|
||||
targetRequest: ChatConversationRequest;
|
||||
@@ -2447,20 +2562,40 @@ export async function createManagedChatShareRoom(payload: ManagedChatShareRoomDr
|
||||
} satisfies ManagedChatShareRoom;
|
||||
}
|
||||
|
||||
export async function saveChatShareRoomAccessPin(
|
||||
export async function saveChatShareRoomSettings(
|
||||
token: string,
|
||||
input: {
|
||||
accessPin?: string | null;
|
||||
accessPinPromptTtlMinutes?: number | null;
|
||||
chatTypeId?: string | null;
|
||||
chatTypeLabel?: string | null;
|
||||
notifyOffline?: boolean | null;
|
||||
},
|
||||
) {
|
||||
const response = await requestChatApi<{ ok: boolean; hasAccessPin: boolean; accessPinPromptTtlMinutes?: number | null }>(
|
||||
const response = await requestChatApi<{
|
||||
ok: boolean;
|
||||
hasAccessPin: boolean;
|
||||
accessPinPromptTtlMinutes?: number | null;
|
||||
conversation?: {
|
||||
sessionId?: string | null;
|
||||
title?: string | null;
|
||||
requestBadgeLabel?: string | null;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
contextLabel?: string | null;
|
||||
contextDescription?: string | null;
|
||||
notifyOffline?: boolean | null;
|
||||
} | null;
|
||||
}>(
|
||||
`/shares/${encodeURIComponent(token)}/room-settings`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
accessPin: input.accessPin,
|
||||
accessPinPromptTtlMinutes: input.accessPinPromptTtlMinutes,
|
||||
chatTypeId: input.chatTypeId,
|
||||
chatTypeLabel: input.chatTypeLabel,
|
||||
notifyOffline: input.notifyOffline,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -2474,6 +2609,18 @@ export async function saveChatShareRoomAccessPin(
|
||||
Number.isFinite(response.accessPinPromptTtlMinutes) && Number(response.accessPinPromptTtlMinutes) >= 0
|
||||
? Math.max(0, Number(response.accessPinPromptTtlMinutes))
|
||||
: 0,
|
||||
conversation: response.conversation
|
||||
? {
|
||||
sessionId: normalizeOptionalText(response.conversation.sessionId),
|
||||
title: normalizeOptionalText(response.conversation.title),
|
||||
requestBadgeLabel: normalizeOptionalText(response.conversation.requestBadgeLabel),
|
||||
chatTypeId: normalizeOptionalText(response.conversation.chatTypeId),
|
||||
lastChatTypeId: normalizeOptionalText(response.conversation.lastChatTypeId),
|
||||
contextLabel: normalizeOptionalText(response.conversation.contextLabel),
|
||||
contextDescription: normalizeOptionalText(response.conversation.contextDescription),
|
||||
notifyOffline: response.conversation.notifyOffline === true,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2543,7 +2690,14 @@ export async function fetchChatShareSnapshot(token: string, options?: { sharePin
|
||||
)
|
||||
: [],
|
||||
},
|
||||
conversation: response.conversation,
|
||||
conversation: {
|
||||
...response.conversation,
|
||||
chatTypeId: normalizeOptionalText(response.conversation?.chatTypeId),
|
||||
lastChatTypeId: normalizeOptionalText(response.conversation?.lastChatTypeId),
|
||||
contextLabel: normalizeOptionalText(response.conversation?.contextLabel),
|
||||
contextDescription: normalizeOptionalText(response.conversation?.contextDescription),
|
||||
notifyOffline: response.conversation?.notifyOffline === true,
|
||||
},
|
||||
rootRequestId: response.rootRequestId,
|
||||
targetRequest: normalizeChatConversationRequest(response.targetRequest),
|
||||
requests: Array.isArray(response.requests) ? response.requests.map((item) => normalizeChatConversationRequest(item)) : [],
|
||||
@@ -2566,6 +2720,7 @@ export async function submitChatShareMessage(
|
||||
token: string,
|
||||
text: string,
|
||||
options?: {
|
||||
mode?: 'queue' | 'direct';
|
||||
parentRequestId?: string | null;
|
||||
},
|
||||
) {
|
||||
@@ -2575,6 +2730,7 @@ export async function submitChatShareMessage(
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
mode: options?.mode === 'direct' ? 'direct' : 'queue',
|
||||
parentRequestId: options?.parentRequestId?.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
@@ -2604,6 +2760,7 @@ export async function submitChatSharePrompt(
|
||||
summaryText?: string | null;
|
||||
attachments?: ChatComposerAttachment[];
|
||||
followupText: string;
|
||||
mode?: 'queue' | 'direct';
|
||||
contextRef?: ChatPromptContextRef | null;
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -14,6 +14,7 @@ type LinkNavigationEvent = {
|
||||
type OpenExternalLinkOptions = {
|
||||
event?: LinkNavigationEvent;
|
||||
onUnsupportedStandalone?: (url: string) => void;
|
||||
allowSameTabFallback?: boolean;
|
||||
};
|
||||
|
||||
function canUseSessionStorage() {
|
||||
@@ -66,6 +67,36 @@ function buildExternalWindowTarget() {
|
||||
return `${EXTERNAL_WINDOW_TARGET_PREFIX}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function openExternalWindow(url: string, target: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openedWindow = window.open('', target);
|
||||
|
||||
if (!openedWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
openedWindow.opener = null;
|
||||
} catch {
|
||||
// Ignore opener access failures in restricted runtimes.
|
||||
}
|
||||
|
||||
try {
|
||||
openedWindow.location.replace(url);
|
||||
} catch {
|
||||
try {
|
||||
openedWindow.location.href = url;
|
||||
} catch {
|
||||
// Leave the popup verification fallback to handle blocked navigations.
|
||||
}
|
||||
}
|
||||
|
||||
return openedWindow;
|
||||
}
|
||||
|
||||
function clickExternalAnchor(url: string, target: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
@@ -151,30 +182,24 @@ function openPreviewRuntimeFallback(url: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleUnsupportedStandaloneFallback(url: string, callback?: (url: string) => void) {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined' || !isAppleMobileStandaloneMode()) {
|
||||
return;
|
||||
function hasOpenedWindowNavigated(openedWindow: Window | null) {
|
||||
if (!openedWindow || openedWindow.closed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const pageStillVisible = document.visibilityState !== 'hidden';
|
||||
const pageStillFocused = typeof document.hasFocus !== 'function' || document.hasFocus();
|
||||
|
||||
if (pageStillVisible && pageStillFocused) {
|
||||
if (openPreviewRuntimeFallback(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (openSameTabFallback(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback?.(url);
|
||||
}
|
||||
}, UNSUPPORTED_STANDALONE_FALLBACK_DELAY_MS);
|
||||
try {
|
||||
return openedWindow.location.href !== 'about:blank';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePopupBlockedFallback(url: string, callback?: (url: string) => void) {
|
||||
function scheduleExternalWindowFallback(
|
||||
url: string,
|
||||
openedWindow: Window | null,
|
||||
allowSameTabFallback: boolean,
|
||||
callback?: (url: string) => void,
|
||||
) {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +212,17 @@ function schedulePopupBlockedFallback(url: string, callback?: (url: string) => v
|
||||
return;
|
||||
}
|
||||
|
||||
if (openSameTabFallback(url)) {
|
||||
if (hasOpenedWindowNavigated(openedWindow)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowSameTabFallback && isAppleMobileStandaloneMode()) {
|
||||
if (openPreviewRuntimeFallback(url)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowSameTabFallback && openSameTabFallback(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -221,15 +256,14 @@ export function openExternalLinkInNewWindow(url: string, options: OpenExternalLi
|
||||
|
||||
persistExternalLinkOpenTimestamp(Date.now());
|
||||
const target = buildExternalWindowTarget();
|
||||
const openedWindow = window.open(url, target, 'noopener,noreferrer');
|
||||
const allowSameTabFallback = options.allowSameTabFallback ?? true;
|
||||
const openedWindow = openExternalWindow(url, target);
|
||||
|
||||
if (openedWindow) {
|
||||
return;
|
||||
if (!openedWindow && allowSameTabFallback) {
|
||||
clickExternalAnchor(url, target);
|
||||
}
|
||||
|
||||
clickExternalAnchor(url, target);
|
||||
scheduleUnsupportedStandaloneFallback(url, options.onUnsupportedStandalone);
|
||||
schedulePopupBlockedFallback(url, options.onUnsupportedStandalone);
|
||||
scheduleExternalWindowFallback(url, openedWindow, allowSameTabFallback, options.onUnsupportedStandalone);
|
||||
}
|
||||
|
||||
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) {
|
||||
|
||||
@@ -1907,6 +1907,9 @@
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--app-theme-prompt);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__options {
|
||||
@@ -1998,6 +2001,9 @@
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__option-meta {
|
||||
@@ -2034,6 +2040,8 @@
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__option-preview-hint {
|
||||
@@ -2099,6 +2107,8 @@
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__optional-pill {
|
||||
@@ -2305,6 +2315,9 @@
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__summary-detail {
|
||||
|
||||
@@ -191,8 +191,10 @@ export type ChatConversationRequest = {
|
||||
chatTypeLabel?: string;
|
||||
requestOrigin?: 'composer' | 'prompt' | null;
|
||||
parentRequestId?: string | null;
|
||||
promptContextRef?: ChatPromptContextRef | null;
|
||||
status: ChatConversationRequestStatus;
|
||||
statusMessage: string | null;
|
||||
retryCount?: number;
|
||||
userMessageId: number | null;
|
||||
userText: string;
|
||||
responseMessageId: number | null;
|
||||
|
||||
@@ -91,6 +91,7 @@ export type ClientNotificationPayload = {
|
||||
body: string;
|
||||
data?: Record<string, string>;
|
||||
threadId?: string;
|
||||
targetDeviceIds?: string[];
|
||||
targetClientIds?: string[];
|
||||
targetAppOrigins?: string[];
|
||||
targetAppDomains?: string[];
|
||||
@@ -902,10 +903,12 @@ export async function showLocalClientNotification(payload: ClientNotificationPay
|
||||
|
||||
export async function sendClientNotification(payload: ClientNotificationPayload) {
|
||||
const notificationData = withCurrentAppOriginMetadata(payload.data);
|
||||
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
|
||||
return request<ClientNotificationSendResult>('/notifications/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
targetDeviceIds,
|
||||
data: notificationData,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
scrollbar-gutter: stable;
|
||||
scroll-padding-bottom: calc(var(--chat-share-page-bottom-padding) + var(--chat-share-page-active-safe-bottom));
|
||||
padding:
|
||||
calc(var(--chat-share-page-top-padding) + var(--chat-share-page-safe-top))
|
||||
@@ -250,6 +250,7 @@
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-share-page__search-summary {
|
||||
@@ -261,8 +262,11 @@
|
||||
.chat-share-page__search-results {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
gap: 8px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
overscroll-behavior: contain;
|
||||
@@ -270,7 +274,7 @@
|
||||
}
|
||||
|
||||
.chat-share-page__search-results--apps {
|
||||
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 136px), 1fr));
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -324,12 +328,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-share-page__search-app-environment .ant-select {
|
||||
flex: 0 1 180px;
|
||||
min-width: 132px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-share-page__search-result-action-group {
|
||||
@@ -364,16 +372,20 @@
|
||||
grid-template-rows: auto auto auto auto;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 128px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(191, 204, 220, 0.8),
|
||||
0 6px 18px rgba(148, 163, 184, 0.1);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-share-page__app-tile:disabled,
|
||||
@@ -388,6 +400,7 @@
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: start;
|
||||
border-radius: 12px;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
@@ -395,6 +408,7 @@
|
||||
}
|
||||
|
||||
.chat-share-page__app-tile-title {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
@@ -409,6 +423,7 @@
|
||||
}
|
||||
|
||||
.chat-share-page__app-tile-description {
|
||||
min-width: 0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
@@ -427,6 +442,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-share-page__app-tile-meta-label,
|
||||
@@ -686,6 +702,7 @@
|
||||
.chat-share-page__settings-item-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -747,6 +764,48 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-share-page__settings-item-meta--detail {
|
||||
color: #64748b;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-share-page__settings-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.chat-share-page__settings-status-badge--latest {
|
||||
background: rgba(219, 234, 254, 0.94);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.chat-share-page__settings-status-badge--unknown {
|
||||
background: rgba(226, 232, 240, 0.92);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.chat-share-page__settings-status-badge--update-available {
|
||||
background: rgba(254, 240, 138, 0.96);
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.chat-share-page__settings-status-badge--build-required {
|
||||
background: rgba(254, 226, 226, 0.96);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.chat-share-page__settings-item--summary .chat-share-page__settings-item-title {
|
||||
color: #1e293b;
|
||||
}
|
||||
@@ -819,6 +878,18 @@
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.chat-share-page__previous-question-button.ant-btn {
|
||||
color: #475569;
|
||||
min-width: 28px;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.chat-share-page__previous-question-button.ant-btn:hover,
|
||||
.chat-share-page__previous-question-button.ant-btn:focus {
|
||||
color: #0f172a;
|
||||
background: rgba(226, 232, 240, 0.88);
|
||||
}
|
||||
|
||||
.chat-share-page__response-reply-button--active.ant-btn,
|
||||
.chat-share-page__response-reply-button.ant-btn:hover,
|
||||
.chat-share-page__response-reply-button.ant-btn:focus {
|
||||
@@ -851,6 +922,101 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-drawer .ant-drawer-header {
|
||||
padding: 12px 18px 10px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-drawer .ant-drawer-title {
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-drawer .ant-drawer-body {
|
||||
padding: 0;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-shell {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: calc(100vh - 48px);
|
||||
padding: 14px 16px 18px;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-tabs .ant-tabs-nav {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-tabs .ant-tabs-tab {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-tabs .ant-tabs-content-holder {
|
||||
min-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-panel-head {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.86);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 14px 34px rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-card--status {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-status-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-checkbox-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-checkbox-group .ant-checkbox-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-share-page__room-settings-toggle-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(191, 219, 254, 0.9);
|
||||
border-radius: 18px;
|
||||
background: rgba(239, 246, 255, 0.92);
|
||||
}
|
||||
|
||||
.chat-share-page__token-usage-select-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1125,6 +1291,76 @@
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
.chat-share-page__program-modal-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-share-page__program-app-shell {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-share-page__program-app-shell--surface {
|
||||
padding: 0;
|
||||
background: linear-gradient(180deg, #f7fafc 0%, #eef3f9 100%);
|
||||
}
|
||||
|
||||
.chat-share-page__program-app-loading {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-share-page__program-app-shell > *,
|
||||
.chat-share-page__program-app-shell .ant-card,
|
||||
.chat-share-page__program-app-shell .ant-card-body,
|
||||
.chat-share-page__program-app-shell .chat-type-management-page,
|
||||
.chat-share-page__program-app-shell .shared-app-settings-page,
|
||||
.chat-share-page__program-app-shell .server-command-page,
|
||||
.chat-share-page__program-app-shell .text-memo-widget {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-share-page__program-app-shell > *,
|
||||
.chat-share-page__program-app-shell .ant-card,
|
||||
.chat-share-page__program-app-shell .ant-card-body,
|
||||
.chat-share-page__program-app-shell .chat-type-management-page,
|
||||
.chat-share-page__program-app-shell .shared-app-settings-page,
|
||||
.chat-share-page__program-app-shell .server-command-page {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-share-page__program-modal .app-chat-panel__preview-frame {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__shell,
|
||||
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__content {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
@@ -1239,7 +1475,7 @@
|
||||
}
|
||||
|
||||
.chat-share-page__search-results--apps {
|
||||
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 116px), 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -1450,6 +1686,8 @@
|
||||
|
||||
.chat-share-page__conversation-panel > .chat-share-page__section-head {
|
||||
top: calc(var(--chat-share-page-top-padding) * -1);
|
||||
margin: -8px -8px 8px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.chat-share-page__conversation-panel,
|
||||
@@ -1784,6 +2022,21 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-share-page__composer-send-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-share-page__composer-send-mode-text.ant-typography {
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-share-page__reply-reference {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1825,6 +2078,26 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chat-share-page__previous-question-modal {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-share-page__previous-question-modal-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.chat-share-page__previous-question-modal-section + .chat-share-page__previous-question-modal-section {
|
||||
border-top: 1px solid #e5eaf1;
|
||||
}
|
||||
|
||||
.chat-share-page__previous-question-modal-text.ant-typography {
|
||||
margin-bottom: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-share-page__composer-topline-actions .app-chat-panel__composer-action-buttons .ant-btn {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
251
src/app/main/pwa/installManifest.ts
Normal file
251
src/app/main/pwa/installManifest.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
type InstallManifestOptions = {
|
||||
backgroundColor?: string;
|
||||
description: string;
|
||||
name: string;
|
||||
scope?: string;
|
||||
shortName?: string;
|
||||
startPath: string;
|
||||
themeColor: string;
|
||||
};
|
||||
|
||||
type SwapInstallDocumentMetadataOptions = {
|
||||
manifestHref: string;
|
||||
title: string;
|
||||
themeColor?: string;
|
||||
};
|
||||
|
||||
function resolveInstallScope(startPath: string, explicitScope?: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return explicitScope ?? '/';
|
||||
}
|
||||
|
||||
if (explicitScope) {
|
||||
const resolvedScopeUrl = new URL(explicitScope, window.location.origin);
|
||||
resolvedScopeUrl.search = '';
|
||||
resolvedScopeUrl.hash = '';
|
||||
return resolvedScopeUrl.toString();
|
||||
}
|
||||
|
||||
const resolvedStartUrl = new URL(startPath, window.location.origin);
|
||||
resolvedStartUrl.search = '';
|
||||
resolvedStartUrl.hash = '';
|
||||
return resolvedStartUrl.toString();
|
||||
}
|
||||
|
||||
function resolveInstallStartUrl(startPath: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return startPath;
|
||||
}
|
||||
|
||||
return new URL(startPath, window.location.origin).toString();
|
||||
}
|
||||
|
||||
function resolveInstallIconUrl(path: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return path;
|
||||
}
|
||||
|
||||
return new URL(path, window.location.origin).toString();
|
||||
}
|
||||
|
||||
export function createInstallManifestObjectUrl(options: InstallManifestOptions) {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const resolvedStartUrl = resolveInstallStartUrl(options.startPath);
|
||||
const manifest = {
|
||||
id: resolvedStartUrl,
|
||||
name: options.name,
|
||||
short_name: options.shortName ?? options.name,
|
||||
description: options.description,
|
||||
theme_color: options.themeColor,
|
||||
background_color: options.backgroundColor ?? '#eff5ff',
|
||||
display: 'standalone',
|
||||
lang: 'ko',
|
||||
scope: resolveInstallScope(options.startPath, options.scope),
|
||||
start_url: resolvedStartUrl,
|
||||
icons: [
|
||||
{
|
||||
src: resolveInstallIconUrl('/pwa-192x192.svg'),
|
||||
sizes: '192x192',
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
{
|
||||
src: resolveInstallIconUrl('/pwa-512x512.svg'),
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return window.URL.createObjectURL(
|
||||
new Blob([JSON.stringify(manifest, null, 2)], {
|
||||
type: 'application/manifest+json',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function swapInstallDocumentMetadata(options: SwapInstallDocumentMetadataOptions) {
|
||||
if (typeof document === 'undefined') {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const head = document.head;
|
||||
let manifestLink = document.querySelector<HTMLLinkElement>('link[rel="manifest"]');
|
||||
const createdManifestLink = !manifestLink;
|
||||
|
||||
if (!manifestLink) {
|
||||
manifestLink = document.createElement('link');
|
||||
manifestLink.rel = 'manifest';
|
||||
head.appendChild(manifestLink);
|
||||
}
|
||||
|
||||
const previousManifestHref = manifestLink.href;
|
||||
manifestLink.href = options.manifestHref;
|
||||
|
||||
let appleTitleMeta = document.querySelector<HTMLMetaElement>('meta[name="apple-mobile-web-app-title"]');
|
||||
const createdAppleTitleMeta = !appleTitleMeta;
|
||||
const previousAppleTitle = appleTitleMeta?.content ?? null;
|
||||
|
||||
if (!appleTitleMeta) {
|
||||
appleTitleMeta = document.createElement('meta');
|
||||
appleTitleMeta.name = 'apple-mobile-web-app-title';
|
||||
head.appendChild(appleTitleMeta);
|
||||
}
|
||||
|
||||
appleTitleMeta.content = options.title;
|
||||
|
||||
let themeColorMeta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
|
||||
const createdThemeColorMeta = !themeColorMeta;
|
||||
const previousThemeColor = themeColorMeta?.content ?? null;
|
||||
|
||||
if (options.themeColor && !themeColorMeta) {
|
||||
themeColorMeta = document.createElement('meta');
|
||||
themeColorMeta.name = 'theme-color';
|
||||
head.appendChild(themeColorMeta);
|
||||
}
|
||||
|
||||
if (options.themeColor && themeColorMeta) {
|
||||
themeColorMeta.content = options.themeColor;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (createdManifestLink) {
|
||||
manifestLink?.remove();
|
||||
} else if (manifestLink) {
|
||||
manifestLink.href = previousManifestHref;
|
||||
}
|
||||
|
||||
if (createdAppleTitleMeta) {
|
||||
appleTitleMeta?.remove();
|
||||
} else if (appleTitleMeta) {
|
||||
appleTitleMeta.content = previousAppleTitle ?? '';
|
||||
}
|
||||
|
||||
if (createdThemeColorMeta) {
|
||||
themeColorMeta?.remove();
|
||||
} else if (themeColorMeta && options.themeColor) {
|
||||
themeColorMeta.content = previousThemeColor ?? '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
type BootstrapInstallMetadataResult = {
|
||||
manifestObjectUrl: string;
|
||||
title: string;
|
||||
themeColor: string;
|
||||
} | null;
|
||||
|
||||
const PLAY_APP_INSTALL_METADATA: Record<string, { title: string; themeColor: string }> = {
|
||||
'baseball-ticket-bay': { title: 'Baseball Ticket Bay', themeColor: '#1b3f91' },
|
||||
'photoprism': { title: 'PhotoPrism', themeColor: '#0f766e' },
|
||||
'photo-puzzle': { title: 'Photo Puzzle', themeColor: '#d97706' },
|
||||
'the-quest': { title: 'The Quest', themeColor: '#7c3aed' },
|
||||
'tetris': { title: 'Tetris', themeColor: '#0f172a' },
|
||||
'e-reader': { title: 'E-Reader', themeColor: '#165dff' },
|
||||
};
|
||||
|
||||
function createCurrentRouteInstallManifestObjectUrl(options: {
|
||||
title: string;
|
||||
themeColor: string;
|
||||
scope?: string;
|
||||
shortName?: string;
|
||||
description: string;
|
||||
}) {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
return createInstallManifestObjectUrl({
|
||||
startPath,
|
||||
scope: options.scope,
|
||||
name: options.title,
|
||||
shortName: options.shortName,
|
||||
description: options.description,
|
||||
themeColor: options.themeColor,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyBootstrapInstallMetadataForCurrentRoute(): BootstrapInstallMetadataResult {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathname = window.location.pathname;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (pathname === '/play/apps') {
|
||||
const appId = searchParams.get('app')?.trim() ?? '';
|
||||
const metadata = PLAY_APP_INSTALL_METADATA[appId];
|
||||
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
manifestObjectUrl: createCurrentRouteInstallManifestObjectUrl({
|
||||
title: metadata.title,
|
||||
shortName: metadata.title,
|
||||
description: `${metadata.title} 앱을 홈 화면에서 바로 엽니다.`,
|
||||
themeColor: metadata.themeColor,
|
||||
scope: pathname,
|
||||
}),
|
||||
title: metadata.title,
|
||||
themeColor: metadata.themeColor,
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname === '/plans/shared-resource') {
|
||||
return {
|
||||
manifestObjectUrl: createCurrentRouteInstallManifestObjectUrl({
|
||||
title: '공유 리소스 관리',
|
||||
shortName: '공유 리소스',
|
||||
description: '공유 리소스 관리 화면을 홈 화면 앱으로 바로 엽니다.',
|
||||
themeColor: '#0f766e',
|
||||
scope: pathname,
|
||||
}),
|
||||
title: '공유 리소스 관리',
|
||||
themeColor: '#0f766e',
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/shares/')) {
|
||||
return {
|
||||
manifestObjectUrl: createCurrentRouteInstallManifestObjectUrl({
|
||||
title: '리소스 공유 채팅방',
|
||||
shortName: '공유채팅',
|
||||
description: '리소스 공유 채팅방을 홈 화면 앱으로 바로 엽니다.',
|
||||
themeColor: '#165dff',
|
||||
scope: pathname,
|
||||
}),
|
||||
title: '리소스 공유 채팅방',
|
||||
themeColor: '#165dff',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -71,6 +71,15 @@ export type SharedResourceTokenActivityRecord = {
|
||||
summary: string;
|
||||
detail: string | null;
|
||||
usageDelta: number;
|
||||
clientIp: string | null;
|
||||
externalIp: string | null;
|
||||
forwardedFor: string | null;
|
||||
realIp: string | null;
|
||||
host: string | null;
|
||||
origin: string | null;
|
||||
referer: string | null;
|
||||
userAgent: string | null;
|
||||
clientId: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -269,6 +278,15 @@ function normalizeSharedResourceTokenActivityRecord(
|
||||
summary: normalizeRequiredText(record.summary),
|
||||
detail: normalizeOptionalText(record.detail),
|
||||
usageDelta: normalizeNumber(record.usageDelta),
|
||||
clientIp: normalizeOptionalText(record.clientIp),
|
||||
externalIp: normalizeOptionalText(record.externalIp),
|
||||
forwardedFor: normalizeOptionalText(record.forwardedFor),
|
||||
realIp: normalizeOptionalText(record.realIp),
|
||||
host: normalizeOptionalText(record.host),
|
||||
origin: normalizeOptionalText(record.origin),
|
||||
referer: normalizeOptionalText(record.referer),
|
||||
userAgent: normalizeOptionalText(record.userAgent),
|
||||
clientId: normalizeOptionalText(record.clientId),
|
||||
createdAt: normalizeRequiredText(record.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1881,6 +1881,9 @@
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--app-theme-prompt);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__options {
|
||||
@@ -1972,6 +1975,9 @@
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__option-meta {
|
||||
@@ -2008,6 +2014,8 @@
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__option-preview-hint {
|
||||
@@ -2073,6 +2081,8 @@
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__optional-pill {
|
||||
@@ -2264,6 +2274,9 @@
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-prompt-card__summary-detail {
|
||||
|
||||
@@ -11,15 +11,38 @@ export const ALLOWED_REGISTRATION_TOKEN =
|
||||
import.meta.env.VITE_ALLOWED_REGISTRATION_TOKEN?.trim() || 'usr_7f3a9c2d8e1b4a6f';
|
||||
const PREVIEW_RUNTIME_TOKEN_STORAGE_KEY = 'work-app.preview-runtime.registered-token';
|
||||
const PREVIEW_APP_ORIGIN = 'https://preview.sm-home.cloud';
|
||||
const TOKEN_STORAGE_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
||||
|
||||
let previewRuntimeTokenMemory = '';
|
||||
|
||||
function removeStorageToken(storage: Storage | null, key: string) {
|
||||
try {
|
||||
storage?.removeItem(key);
|
||||
} catch {
|
||||
// Ignore storage access errors in restricted preview runtimes.
|
||||
}
|
||||
}
|
||||
|
||||
function getRegisteredTokenStorage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.sessionStorage;
|
||||
return {
|
||||
primaryStorage: window.localStorage,
|
||||
legacyStorage: window.sessionStorage,
|
||||
};
|
||||
}
|
||||
|
||||
function getPreviewRuntimeTokenStorage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
primaryStorage: window.localStorage,
|
||||
legacyStorage: window.sessionStorage,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToken(value: string | null | undefined) {
|
||||
@@ -46,44 +69,141 @@ function writeStorageToken(storage: Storage | null, key: string, token: string)
|
||||
}
|
||||
}
|
||||
|
||||
function readCookieToken(key: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const encodedKey = `${encodeURIComponent(key)}=`;
|
||||
const cookieEntry = document.cookie
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.startsWith(encodedKey));
|
||||
|
||||
if (!cookieEntry) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalizeToken(decodeURIComponent(cookieEntry.slice(encodedKey.length)));
|
||||
}
|
||||
|
||||
function writeCookieToken(key: string, token: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
|
||||
if (!token) {
|
||||
document.cookie = `${encodedKey}=; path=/; max-age=0; SameSite=Lax`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.cookie = `${encodedKey}=${encodeURIComponent(token)}; path=/; max-age=${TOKEN_STORAGE_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
function bootstrapRegisteredAccessToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenFromUrl = readPreviewRuntimeTokenFromUrl();
|
||||
const storedToken = readStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY);
|
||||
const registeredTokenStorage = getRegisteredTokenStorage();
|
||||
const storedToken = readStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
const legacyStoredToken = readStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
const cookieStoredToken = readCookieToken(TOKEN_ACCESS_STORAGE_KEY);
|
||||
|
||||
if (isPreviewRuntime()) {
|
||||
const previewRuntimeTokenStorage = getPreviewRuntimeTokenStorage();
|
||||
const cookieStoredPreviewToken = readCookieToken(PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
|
||||
if (!tokenFromUrl) {
|
||||
const storedPreviewToken = readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
const storedPreviewToken = readStorageToken(
|
||||
previewRuntimeTokenStorage?.primaryStorage ?? null,
|
||||
PREVIEW_RUNTIME_TOKEN_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (storedPreviewToken) {
|
||||
previewRuntimeTokenMemory = storedPreviewToken;
|
||||
writeCookieToken(PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, storedPreviewToken);
|
||||
removeStorageToken(previewRuntimeTokenStorage?.legacyStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyStoredPreviewToken = readStorageToken(
|
||||
previewRuntimeTokenStorage?.legacyStorage ?? null,
|
||||
PREVIEW_RUNTIME_TOKEN_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (legacyStoredPreviewToken) {
|
||||
previewRuntimeTokenMemory = legacyStoredPreviewToken;
|
||||
writeStorageToken(
|
||||
previewRuntimeTokenStorage?.primaryStorage ?? null,
|
||||
PREVIEW_RUNTIME_TOKEN_STORAGE_KEY,
|
||||
legacyStoredPreviewToken,
|
||||
);
|
||||
writeCookieToken(PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, legacyStoredPreviewToken);
|
||||
removeStorageToken(previewRuntimeTokenStorage?.legacyStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cookieStoredPreviewToken) {
|
||||
previewRuntimeTokenMemory = cookieStoredPreviewToken;
|
||||
writeStorageToken(
|
||||
previewRuntimeTokenStorage?.primaryStorage ?? null,
|
||||
PREVIEW_RUNTIME_TOKEN_STORAGE_KEY,
|
||||
cookieStoredPreviewToken,
|
||||
);
|
||||
removeStorageToken(previewRuntimeTokenStorage?.legacyStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.location.origin === PREVIEW_APP_ORIGIN) {
|
||||
previewRuntimeTokenMemory = ALLOWED_REGISTRATION_TOKEN;
|
||||
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
writeStorageToken(
|
||||
previewRuntimeTokenStorage?.primaryStorage ?? null,
|
||||
PREVIEW_RUNTIME_TOKEN_STORAGE_KEY,
|
||||
ALLOWED_REGISTRATION_TOKEN,
|
||||
);
|
||||
writeCookieToken(PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
removeStorageToken(previewRuntimeTokenStorage?.legacyStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
previewRuntimeTokenMemory = tokenFromUrl;
|
||||
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, tokenFromUrl);
|
||||
writeStorageToken(previewRuntimeTokenStorage?.primaryStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, tokenFromUrl);
|
||||
writeCookieToken(PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, tokenFromUrl);
|
||||
removeStorageToken(previewRuntimeTokenStorage?.legacyStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
clearPreviewRuntimeTokenFromUrl();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tokenFromUrl) {
|
||||
if (!storedToken && legacyStoredToken) {
|
||||
writeStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY, legacyStoredToken);
|
||||
writeCookieToken(TOKEN_ACCESS_STORAGE_KEY, legacyStoredToken);
|
||||
removeStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storedToken && cookieStoredToken) {
|
||||
writeStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY, cookieStoredToken);
|
||||
removeStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
|
||||
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
writeStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
writeCookieToken(TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
removeStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, tokenFromUrl);
|
||||
writeStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY, tokenFromUrl);
|
||||
writeCookieToken(TOKEN_ACCESS_STORAGE_KEY, tokenFromUrl);
|
||||
removeStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
clearPreviewRuntimeTokenFromUrl();
|
||||
}
|
||||
|
||||
@@ -99,22 +219,61 @@ export function getRegisteredAccessToken() {
|
||||
}
|
||||
|
||||
if (isPreviewRuntime()) {
|
||||
const previewRuntimeTokenStorage = getPreviewRuntimeTokenStorage();
|
||||
const previewToken =
|
||||
readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY) || previewRuntimeTokenMemory;
|
||||
readStorageToken(previewRuntimeTokenStorage?.primaryStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY) ||
|
||||
readStorageToken(previewRuntimeTokenStorage?.legacyStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY) ||
|
||||
previewRuntimeTokenMemory;
|
||||
|
||||
if (!previewToken && window.location.origin === PREVIEW_APP_ORIGIN) {
|
||||
previewRuntimeTokenMemory = ALLOWED_REGISTRATION_TOKEN;
|
||||
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
writeStorageToken(
|
||||
previewRuntimeTokenStorage?.primaryStorage ?? null,
|
||||
PREVIEW_RUNTIME_TOKEN_STORAGE_KEY,
|
||||
ALLOWED_REGISTRATION_TOKEN,
|
||||
);
|
||||
writeCookieToken(PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
removeStorageToken(previewRuntimeTokenStorage?.legacyStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
return ALLOWED_REGISTRATION_TOKEN;
|
||||
}
|
||||
|
||||
if (previewToken) {
|
||||
writeStorageToken(previewRuntimeTokenStorage?.primaryStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, previewToken);
|
||||
writeCookieToken(PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, previewToken);
|
||||
removeStorageToken(previewRuntimeTokenStorage?.legacyStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
return previewToken;
|
||||
}
|
||||
|
||||
const storedToken = readStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY);
|
||||
const registeredTokenStorage = getRegisteredTokenStorage();
|
||||
const storedToken = readStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
const cookieStoredToken = readCookieToken(TOKEN_ACCESS_STORAGE_KEY);
|
||||
|
||||
if (storedToken) {
|
||||
writeCookieToken(TOKEN_ACCESS_STORAGE_KEY, storedToken);
|
||||
removeStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
} else {
|
||||
const legacyStoredToken = readStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
|
||||
if (legacyStoredToken) {
|
||||
writeStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY, legacyStoredToken);
|
||||
writeCookieToken(TOKEN_ACCESS_STORAGE_KEY, legacyStoredToken);
|
||||
removeStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
return legacyStoredToken;
|
||||
}
|
||||
|
||||
if (cookieStoredToken) {
|
||||
writeStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY, cookieStoredToken);
|
||||
removeStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
return cookieStoredToken;
|
||||
}
|
||||
}
|
||||
|
||||
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
|
||||
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
writeStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
writeCookieToken(TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
removeStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
return ALLOWED_REGISTRATION_TOKEN;
|
||||
}
|
||||
|
||||
@@ -133,10 +292,16 @@ export function setRegisteredAccessToken(token: string | null | undefined) {
|
||||
const normalizedToken = normalizeToken(token);
|
||||
|
||||
if (isPreviewRuntime()) {
|
||||
const previewRuntimeTokenStorage = getPreviewRuntimeTokenStorage();
|
||||
previewRuntimeTokenMemory = normalizedToken;
|
||||
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, normalizedToken);
|
||||
writeStorageToken(previewRuntimeTokenStorage?.primaryStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, normalizedToken);
|
||||
writeCookieToken(PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, normalizedToken);
|
||||
removeStorageToken(previewRuntimeTokenStorage?.legacyStorage ?? null, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
|
||||
} else {
|
||||
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
|
||||
const registeredTokenStorage = getRegisteredTokenStorage();
|
||||
writeStorageToken(registeredTokenStorage?.primaryStorage ?? null, TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
|
||||
writeCookieToken(TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
|
||||
removeStorageToken(registeredTokenStorage?.legacyStorage ?? null, TOKEN_ACCESS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(TOKEN_ACCESS_SYNC_EVENT));
|
||||
|
||||
@@ -19,6 +19,25 @@ export type TokenSettingRecord = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TokenSettingActivityRecord = {
|
||||
id: number;
|
||||
settingId: string;
|
||||
activityType: 'created' | 'updated' | 'deleted';
|
||||
actorLabel: string | null;
|
||||
summary: string;
|
||||
detail: string | null;
|
||||
clientIp: string | null;
|
||||
externalIp: string | null;
|
||||
forwardedFor: string | null;
|
||||
realIp: string | null;
|
||||
host: string | null;
|
||||
origin: string | null;
|
||||
referer: string | null;
|
||||
userAgent: string | null;
|
||||
clientId: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type TokenSettingInput = {
|
||||
originalId?: string;
|
||||
id?: string;
|
||||
@@ -40,6 +59,7 @@ const TOKEN_SETTINGS_REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
type TokenSettingsRequestOptions = {
|
||||
shareToken?: string | null;
|
||||
path?: string | null;
|
||||
};
|
||||
|
||||
class TokenSettingApiError extends Error {
|
||||
@@ -209,7 +229,7 @@ async function requestOnce<T>(baseUrl: string, init?: RequestInit, options?: Tok
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${TOKEN_SETTINGS_API_PATH}`, {
|
||||
const response = await fetch(`${baseUrl}${options?.path?.trim() || TOKEN_SETTINGS_API_PATH}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
@@ -262,6 +282,27 @@ async function requestTokenSettings<T>(init?: RequestInit, options?: TokenSettin
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeActivityRecord(record: Partial<TokenSettingActivityRecord>): TokenSettingActivityRecord {
|
||||
return {
|
||||
id: Number(record.id ?? 0),
|
||||
settingId: normalizeSettingId(record.settingId),
|
||||
activityType: record.activityType === 'created' || record.activityType === 'deleted' ? record.activityType : 'updated',
|
||||
actorLabel: normalizeText(record.actorLabel) || null,
|
||||
summary: normalizeText(record.summary),
|
||||
detail: normalizeText(record.detail) || null,
|
||||
clientIp: normalizeText(record.clientIp) || null,
|
||||
externalIp: normalizeText(record.externalIp) || null,
|
||||
forwardedFor: normalizeText(record.forwardedFor) || null,
|
||||
realIp: normalizeText(record.realIp) || null,
|
||||
host: normalizeText(record.host) || null,
|
||||
origin: normalizeText(record.origin) || null,
|
||||
referer: normalizeText(record.referer) || null,
|
||||
userAgent: normalizeText(record.userAgent) || null,
|
||||
clientId: normalizeText(record.clientId) || null,
|
||||
createdAt: normalizeText(record.createdAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTokenSettingsFromServer(options?: TokenSettingsRequestOptions) {
|
||||
const response = await requestTokenSettings<{ ok: boolean; tokenSettings: Partial<TokenSettingRecord>[] | null }>({
|
||||
method: 'GET',
|
||||
@@ -280,6 +321,20 @@ async function saveTokenSettingsToServer(items: TokenSettingRecord[], options?:
|
||||
return sanitizeTokenSettings(response.tokenSettings);
|
||||
}
|
||||
|
||||
export async function fetchTokenSettingActivities(settingId: string, options?: TokenSettingsRequestOptions) {
|
||||
const normalizedSettingId = normalizeSettingId(settingId);
|
||||
if (!normalizedSettingId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await requestTokenSettings<{ ok: boolean; activities: Partial<TokenSettingActivityRecord>[] | null }>(
|
||||
{ method: 'GET' },
|
||||
{ shareToken: options?.shareToken, path: `${TOKEN_SETTINGS_API_PATH}/${encodeURIComponent(normalizedSettingId)}/activities` },
|
||||
);
|
||||
|
||||
return Array.isArray(response.activities) ? response.activities.map((item) => normalizeActivityRecord(item)) : [];
|
||||
}
|
||||
|
||||
export function upsertTokenSetting(items: TokenSettingRecord[], input: TokenSettingInput) {
|
||||
const nextItem = normalizeTokenSetting({
|
||||
...input,
|
||||
|
||||
Reference in New Issue
Block a user