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,
|
||||
|
||||
@@ -110,7 +110,9 @@
|
||||
.process-flow__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
@@ -183,4 +185,10 @@
|
||||
.process-flow__label-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.process-flow__status {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,14 @@ import type {
|
||||
ServerRestartReservation,
|
||||
ServerRestartReservationStatus,
|
||||
ServerRestartReservationWorkItem,
|
||||
TestServerDeploymentPhase,
|
||||
TestServerDeploymentState,
|
||||
TestServerDeploymentStep,
|
||||
TestServerDeploymentStepKey,
|
||||
WorkServerDeploymentPhase,
|
||||
WorkServerDeploymentState,
|
||||
WorkServerDeploymentStep,
|
||||
WorkServerDeploymentStepKey,
|
||||
} from './types';
|
||||
|
||||
export class ServerCommandApiError extends Error {
|
||||
@@ -259,6 +267,7 @@ function normalizeServerCommandItem(value: unknown): ServerCommandItem {
|
||||
commandScript: typeof item.commandScript === 'string' ? item.commandScript : '-',
|
||||
commandWorkingDirectory: typeof item.commandWorkingDirectory === 'string' ? item.commandWorkingDirectory : '-',
|
||||
errorMessage: typeof item.errorMessage === 'string' ? item.errorMessage : null,
|
||||
deployment: normalizeWorkServerDeploymentState(item.deployment),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -343,12 +352,14 @@ function extractServerCommandActionResult(response: unknown): ServerCommandActio
|
||||
commandOutput?: unknown;
|
||||
output?: unknown;
|
||||
restartState?: unknown;
|
||||
testDeployment?: unknown;
|
||||
data?: {
|
||||
item?: unknown;
|
||||
server?: unknown;
|
||||
commandOutput?: unknown;
|
||||
output?: unknown;
|
||||
restartState?: unknown;
|
||||
testDeployment?: unknown;
|
||||
};
|
||||
};
|
||||
const nestedData = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) ? payload.data : null;
|
||||
@@ -365,9 +376,235 @@ function extractServerCommandActionResult(response: unknown): ServerCommandActio
|
||||
item: normalizeServerCommandItem(item),
|
||||
commandOutput: typeof commandOutput === 'string' ? commandOutput : null,
|
||||
restartState: restartState === 'accepted' ? 'accepted' : 'completed',
|
||||
deployment: normalizeWorkServerDeploymentState(payload.deployment ?? nestedData?.deployment),
|
||||
testDeployment: normalizeTestServerDeploymentState(payload.testDeployment ?? nestedData?.testDeployment),
|
||||
};
|
||||
}
|
||||
|
||||
const WORK_SERVER_DEPLOYMENT_STEP_KEYS = [
|
||||
'build-target-slot',
|
||||
'verify-target-health',
|
||||
'switch-proxy',
|
||||
'drain-previous-slot',
|
||||
'rebuild-previous-slot',
|
||||
'recover-interrupted-chat',
|
||||
] as const;
|
||||
|
||||
function normalizeWorkServerDeploymentStepKey(value: unknown): WorkServerDeploymentStepKey | null {
|
||||
return WORK_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as WorkServerDeploymentStepKey)
|
||||
? (value as WorkServerDeploymentStepKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentPhase(value: unknown): WorkServerDeploymentPhase {
|
||||
return value === 'build-target-slot'
|
||||
|| value === 'verify-target-health'
|
||||
|| value === 'switch-proxy'
|
||||
|| value === 'drain-previous-slot'
|
||||
|| value === 'rebuild-previous-slot'
|
||||
|| value === 'recover-interrupted-chat'
|
||||
|| value === 'completed'
|
||||
|| value === 'failed'
|
||||
? value
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentSteps(value: unknown): WorkServerDeploymentStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({
|
||||
key,
|
||||
status: 'pending',
|
||||
detail: null,
|
||||
updatedAt: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const normalizedByKey = new Map<WorkServerDeploymentStepKey, WorkServerDeploymentStep>();
|
||||
|
||||
value.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = item as Record<string, unknown>;
|
||||
const key = normalizeWorkServerDeploymentStepKey(candidate.key);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedByKey.set(key, {
|
||||
key,
|
||||
status:
|
||||
candidate.status === 'running'
|
||||
|| candidate.status === 'completed'
|
||||
|| candidate.status === 'failed'
|
||||
|| candidate.status === 'pending'
|
||||
? candidate.status
|
||||
: 'pending',
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
updatedAt: typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null,
|
||||
});
|
||||
});
|
||||
|
||||
return WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? {
|
||||
key,
|
||||
status: 'pending',
|
||||
detail: null,
|
||||
updatedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentState(value: unknown): WorkServerDeploymentState | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
status: item.status === 'running' || item.status === 'completed' || item.status === 'failed' ? item.status : 'idle',
|
||||
phase: normalizeWorkServerDeploymentPhase(item.phase),
|
||||
summary: typeof item.summary === 'string' ? item.summary : null,
|
||||
startedAt: typeof item.startedAt === 'string' ? item.startedAt : null,
|
||||
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : null,
|
||||
completedAt: typeof item.completedAt === 'string' ? item.completedAt : null,
|
||||
activeSlot: item.activeSlot === 'blue' || item.activeSlot === 'green' ? item.activeSlot : null,
|
||||
targetSlot: item.targetSlot === 'blue' || item.targetSlot === 'green' ? item.targetSlot : null,
|
||||
previousSlot: item.previousSlot === 'blue' || item.previousSlot === 'green' ? item.previousSlot : null,
|
||||
targetContainer: typeof item.targetContainer === 'string' ? item.targetContainer : null,
|
||||
previousContainer: typeof item.previousContainer === 'string' ? item.previousContainer : null,
|
||||
previousSlotActiveChatRequestCount: typeof item.previousSlotActiveChatRequestCount === 'number' ? item.previousSlotActiveChatRequestCount : null,
|
||||
previousSlotQueuedChatRequestCount: typeof item.previousSlotQueuedChatRequestCount === 'number' ? item.previousSlotQueuedChatRequestCount : null,
|
||||
recoveredSessionCount: typeof item.recoveredSessionCount === 'number' ? item.recoveredSessionCount : null,
|
||||
recoveredRestartedCount: typeof item.recoveredRestartedCount === 'number' ? item.recoveredRestartedCount : null,
|
||||
recoveredRequeuedCount: typeof item.recoveredRequeuedCount === 'number' ? item.recoveredRequeuedCount : null,
|
||||
lastError: typeof item.lastError === 'string' ? item.lastError : null,
|
||||
logExcerpt: typeof item.logExcerpt === 'string' ? item.logExcerpt : null,
|
||||
steps: normalizeWorkServerDeploymentSteps(item.steps),
|
||||
};
|
||||
}
|
||||
|
||||
function extractWorkServerDeploymentState(response: unknown): WorkServerDeploymentState | null {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = response as {
|
||||
item?: unknown;
|
||||
data?: {
|
||||
item?: unknown;
|
||||
};
|
||||
};
|
||||
const nestedData = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) ? payload.data : null;
|
||||
return normalizeWorkServerDeploymentState(payload.item ?? nestedData?.item ?? null);
|
||||
}
|
||||
|
||||
const TEST_SERVER_DEPLOYMENT_STEP_KEYS = [
|
||||
'commit-main-worktree',
|
||||
'push-origin-main',
|
||||
'build-test-app',
|
||||
'deploy-test-server',
|
||||
] as const;
|
||||
|
||||
function normalizeTestServerDeploymentStepKey(value: unknown): TestServerDeploymentStepKey | null {
|
||||
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as TestServerDeploymentStepKey)
|
||||
? (value as TestServerDeploymentStepKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentPhase(value: unknown): TestServerDeploymentPhase {
|
||||
return value === 'commit-main-worktree'
|
||||
|| value === 'push-origin-main'
|
||||
|| value === 'build-test-app'
|
||||
|| value === 'deploy-test-server'
|
||||
|| value === 'completed'
|
||||
|| value === 'failed'
|
||||
? value
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentSteps(value: unknown): TestServerDeploymentStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({
|
||||
key,
|
||||
status: 'pending',
|
||||
detail: null,
|
||||
updatedAt: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const normalizedByKey = new Map<TestServerDeploymentStepKey, TestServerDeploymentStep>();
|
||||
|
||||
value.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = item as Record<string, unknown>;
|
||||
const key = normalizeTestServerDeploymentStepKey(candidate.key);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedByKey.set(key, {
|
||||
key,
|
||||
status:
|
||||
candidate.status === 'running'
|
||||
|| candidate.status === 'completed'
|
||||
|| candidate.status === 'failed'
|
||||
|| candidate.status === 'pending'
|
||||
? candidate.status
|
||||
: 'pending',
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
updatedAt: typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null,
|
||||
});
|
||||
});
|
||||
|
||||
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? {
|
||||
key,
|
||||
status: 'pending',
|
||||
detail: null,
|
||||
updatedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentState(value: unknown): TestServerDeploymentState | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
status: item.status === 'running' || item.status === 'completed' || item.status === 'failed' ? item.status : 'idle',
|
||||
phase: normalizeTestServerDeploymentPhase(item.phase),
|
||||
summary: typeof item.summary === 'string' ? item.summary : null,
|
||||
startedAt: typeof item.startedAt === 'string' ? item.startedAt : null,
|
||||
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : null,
|
||||
completedAt: typeof item.completedAt === 'string' ? item.completedAt : null,
|
||||
lastError: typeof item.lastError === 'string' ? item.lastError : null,
|
||||
logExcerpt: typeof item.logExcerpt === 'string' ? item.logExcerpt : null,
|
||||
steps: normalizeTestServerDeploymentSteps(item.steps),
|
||||
};
|
||||
}
|
||||
|
||||
function extractTestServerDeploymentState(response: unknown): TestServerDeploymentState | null {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = response as {
|
||||
item?: unknown;
|
||||
data?: {
|
||||
item?: unknown;
|
||||
};
|
||||
};
|
||||
const nestedData = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) ? payload.data : null;
|
||||
return normalizeTestServerDeploymentState(payload.item ?? nestedData?.item ?? null);
|
||||
}
|
||||
|
||||
function normalizeServerRestartReservationStatus(value: unknown): ServerRestartReservationStatus {
|
||||
return value === 'waiting'
|
||||
|| value === 'ready'
|
||||
@@ -538,6 +775,42 @@ export async function restartServerCommand(key: ServerCommandKey, options?: { si
|
||||
return extractServerCommandActionResult(response);
|
||||
}
|
||||
|
||||
export async function deployWorkServerCommand(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||
const response = await request<unknown>('/server-commands/work-server/actions/deploy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
signal: options?.signal,
|
||||
timeoutMs: 12000,
|
||||
}, { shareToken: options?.shareToken });
|
||||
|
||||
return extractServerCommandActionResult(response);
|
||||
}
|
||||
|
||||
export async function deployTestServerCommand(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||
const response = await request<unknown>('/server-commands/test/actions/deploy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
signal: options?.signal,
|
||||
timeoutMs: 120000,
|
||||
}, { shareToken: options?.shareToken });
|
||||
|
||||
return extractServerCommandActionResult(response);
|
||||
}
|
||||
|
||||
export async function fetchWorkServerDeploymentStatus(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||
const response = await request<unknown>('/server-commands/work-server/deployment', {
|
||||
signal: options?.signal,
|
||||
}, { shareToken: options?.shareToken });
|
||||
return extractWorkServerDeploymentState(response);
|
||||
}
|
||||
|
||||
export async function fetchTestServerDeploymentStatus(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||
const response = await request<unknown>('/server-commands/test/deployment', {
|
||||
signal: options?.signal,
|
||||
}, { shareToken: options?.shareToken });
|
||||
return extractTestServerDeploymentState(response);
|
||||
}
|
||||
|
||||
export async function fetchServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
|
||||
const response = await request<unknown>('/server-commands/restart-reservation', {
|
||||
signal: options?.signal,
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
overscroll-behavior: none;
|
||||
overscroll-behavior-y: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
padding: 0 0 calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
background: #f3f6fb;
|
||||
}
|
||||
|
||||
.server-command-page.ant-space,
|
||||
@@ -22,77 +26,37 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__alert-body {
|
||||
.server-command-page__surface {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.server-command-page__alert-code {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #fff2f0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
.server-command-page__surface--toolbar {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.server-command-page__card,
|
||||
.server-command-page__server-card {
|
||||
border-radius: 24px;
|
||||
.server-command-page__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__reservation-card {
|
||||
border: 1px solid #d6e4ff;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.server-command-page__card--shared {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 23, 42, 0.96) 0%, rgba(30, 41, 59, 0.94) 52%, rgba(37, 99, 235, 0.88) 100%);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.server-command-page__card--shared .server-command-page__title.ant-typography,
|
||||
.server-command-page__card--shared .server-command-page__copy.ant-typography {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.server-command-page__server-card {
|
||||
.server-command-page__toolbar-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-command-page__server-card--shared {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
|
||||
box-shadow:
|
||||
0 20px 44px rgba(15, 23, 42, 0.12),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.server-command-page__server-card--shared-compact {
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 12px 28px rgba(15, 23, 42, 0.08),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.server-command-page__server-card--online {
|
||||
border-color: rgba(147, 197, 253, 0.72);
|
||||
}
|
||||
|
||||
.server-command-page__server-card--degraded {
|
||||
border-color: rgba(253, 230, 138, 0.92);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 251, 235, 0.95) 0%, rgba(255, 255, 255, 0.98) 100%);
|
||||
}
|
||||
|
||||
.server-command-page__server-card--offline {
|
||||
border-color: rgba(254, 202, 202, 0.9);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(254, 242, 242, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%);
|
||||
.server-command-page__toolbar-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.server-command-page__title.ant-typography,
|
||||
@@ -100,300 +64,267 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__title-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.server-command-page__title-row .ant-space-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.server-command-page__copy.ant-typography {
|
||||
max-width: 760px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__shared-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-toolbar-chips {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip-label {
|
||||
color: rgba(226, 232, 240, 0.72);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip-value {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip--online,
|
||||
.server-command-page__toolbar-chip--latest {
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip--degraded,
|
||||
.server-command-page__toolbar-chip--update-available {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip--offline,
|
||||
.server-command-page__toolbar-chip--build-required {
|
||||
background: rgba(220, 38, 38, 0.2);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.server-command-page__toolbar-chip--unknown,
|
||||
.server-command-page__toolbar-chip--info {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.server-command-page__status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.server-command-page__status-dot--online,
|
||||
.server-command-page__status-dot--latest {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.server-command-page__status-dot--degraded,
|
||||
.server-command-page__status-dot--update-available {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.server-command-page__status-dot--offline,
|
||||
.server-command-page__status-dot--build-required {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.server-command-page__status-dot--unknown {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.server-command-page__summary-grid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(226, 232, 240, 0.7);
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.server-command-page__shared-pill--online,
|
||||
.server-command-page__shared-pill--latest {
|
||||
background: rgba(219, 234, 254, 0.95);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.server-command-page__shared-pill--degraded,
|
||||
.server-command-page__shared-pill--update-available {
|
||||
background: rgba(254, 240, 138, 0.55);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.server-command-page__shared-pill--offline,
|
||||
.server-command-page__shared-pill--build-required {
|
||||
background: rgba(254, 226, 226, 0.92);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-command-page__shared-stat {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-summary.ant-typography,
|
||||
.server-command-page__shared-server-footer.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-summary.ant-typography {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button--shared-compact {
|
||||
min-width: 88px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.server-command-page__summary-grid .ant-statistic {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.server-command-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.server-command-page__copy.ant-typography,
|
||||
.server-command-page__summary.ant-typography,
|
||||
.server-command-page__preview.ant-typography,
|
||||
.server-command-page__shared-server-footer.ant-typography,
|
||||
.server-command-page__work-detail.ant-typography,
|
||||
.server-command-page__command.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__copy.ant-typography {
|
||||
margin-top: 2px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.server-command-page__status-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.server-command-page__status-summary-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-command-page__status-summary-item strong {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.server-command-page__status-summary-item--online {
|
||||
color: #166534;
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.server-command-page__status-summary-item--latest {
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.server-command-page__status-summary-item--degraded,
|
||||
.server-command-page__status-summary-item--update-available {
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.server-command-page__status-summary-item--offline,
|
||||
.server-command-page__status-summary-item--build-required {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.server-command-page__status-summary-item--unknown,
|
||||
.server-command-page__status-summary-item--info {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.server-command-page__reservation-panel {
|
||||
border-color: #dbeafe;
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.server-command-page__alert-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__alert-code,
|
||||
.server-command-page__preview,
|
||||
.server-command-page__command {
|
||||
.server-command-page__work-item {
|
||||
display: block;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: #f7faff;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.server-command-page__preview-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__alert-code {
|
||||
background: #fff2f0;
|
||||
}
|
||||
|
||||
.server-command-page__alert-text {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.server-command-page__compact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.server-command-page__compact-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 12px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button {
|
||||
min-width: 96px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-command-page__work-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__work-item {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: #f7faff;
|
||||
.server-command-page__control-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__work-detail.ant-typography {
|
||||
.server-command-page__control-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-color: #d7deea;
|
||||
}
|
||||
|
||||
.server-command-page__control-main {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.server-command-page__control-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.server-command-page__control-title.ant-typography {
|
||||
margin-bottom: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.server-command-page__status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-command-page__status-badge--online,
|
||||
.server-command-page__status-badge--latest {
|
||||
border-color: #93c5fd;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.server-command-page__status-badge--degraded,
|
||||
.server-command-page__status-badge--update-available {
|
||||
border-color: #fcd34d;
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.server-command-page__status-badge--offline,
|
||||
.server-command-page__status-badge--build-required {
|
||||
border-color: #fca5a5;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.server-command-page__status-badge--unknown,
|
||||
.server-command-page__status-badge--info {
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.server-command-page__control-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-command-page__control-meta + .server-command-page__control-meta {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.server-command-page__control-meta .ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__meta .ant-descriptions-item-label {
|
||||
width: 104px;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button--shared {
|
||||
min-width: 180px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
.server-command-page__control-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.server-command-page__shared-server-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.server-command-page__shared-server-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.server-command-page__shared-toolbar {
|
||||
.server-command-page__toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button--shared {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button--shared-compact {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__server-card .ant-card-head {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.server-command-page__server-card .ant-card-head-wrapper {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-command-page__server-card .ant-card-extra {
|
||||
margin-inline-start: 0;
|
||||
.server-command-page__toolbar-side {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.server-command-page__restart-button {
|
||||
width: 100%;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.server-command-page__server-card .ant-card-body {
|
||||
padding-inline: 16px;
|
||||
.server-command-page__control-card {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.server-command-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
.server-command-page__control-button {
|
||||
min-width: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,97 @@ export type ServerCommandItem = {
|
||||
commandScript: string;
|
||||
commandWorkingDirectory: string;
|
||||
errorMessage: string | null;
|
||||
deployment: WorkServerDeploymentState | null;
|
||||
};
|
||||
|
||||
export type ServerCommandActionResult = {
|
||||
item: ServerCommandItem;
|
||||
commandOutput: string | null;
|
||||
restartState: 'completed' | 'accepted';
|
||||
deployment?: WorkServerDeploymentState | null;
|
||||
testDeployment?: TestServerDeploymentState | null;
|
||||
};
|
||||
|
||||
export type WorkServerDeploymentStepKey =
|
||||
| 'build-target-slot'
|
||||
| 'verify-target-health'
|
||||
| 'switch-proxy'
|
||||
| 'drain-previous-slot'
|
||||
| 'rebuild-previous-slot'
|
||||
| 'recover-interrupted-chat';
|
||||
|
||||
export type WorkServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type WorkServerDeploymentStep = {
|
||||
key: WorkServerDeploymentStepKey;
|
||||
status: WorkServerDeploymentStepStatus;
|
||||
detail: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type WorkServerDeploymentPhase =
|
||||
| 'idle'
|
||||
| 'build-target-slot'
|
||||
| 'verify-target-health'
|
||||
| 'switch-proxy'
|
||||
| 'drain-previous-slot'
|
||||
| 'rebuild-previous-slot'
|
||||
| 'recover-interrupted-chat'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export type WorkServerDeploymentState = {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||
phase: WorkServerDeploymentPhase;
|
||||
summary: string | null;
|
||||
startedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
completedAt: string | null;
|
||||
activeSlot: 'blue' | 'green' | null;
|
||||
targetSlot: 'blue' | 'green' | null;
|
||||
previousSlot: 'blue' | 'green' | null;
|
||||
targetContainer: string | null;
|
||||
previousContainer: string | null;
|
||||
previousSlotActiveChatRequestCount: number | null;
|
||||
previousSlotQueuedChatRequestCount: number | null;
|
||||
recoveredSessionCount: number | null;
|
||||
recoveredRestartedCount: number | null;
|
||||
recoveredRequeuedCount: number | null;
|
||||
lastError: string | null;
|
||||
logExcerpt: string | null;
|
||||
steps: WorkServerDeploymentStep[];
|
||||
};
|
||||
|
||||
export type TestServerDeploymentStepKey = 'commit-main-worktree' | 'push-origin-main' | 'build-test-app' | 'deploy-test-server';
|
||||
|
||||
export type TestServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type TestServerDeploymentStep = {
|
||||
key: TestServerDeploymentStepKey;
|
||||
status: TestServerDeploymentStepStatus;
|
||||
detail: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type TestServerDeploymentPhase =
|
||||
| 'idle'
|
||||
| 'commit-main-worktree'
|
||||
| 'push-origin-main'
|
||||
| 'build-test-app'
|
||||
| 'deploy-test-server'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export type TestServerDeploymentState = {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||
phase: TestServerDeploymentPhase;
|
||||
summary: string | null;
|
||||
startedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
completedAt: string | null;
|
||||
lastError: string | null;
|
||||
logExcerpt: string | null;
|
||||
steps: TestServerDeploymentStep[];
|
||||
};
|
||||
|
||||
export type ServerRestartReservationStatus =
|
||||
|
||||
@@ -158,7 +158,7 @@ function parseConfig(value: unknown): GpsConfig {
|
||||
|
||||
function sendGpsPushNotification(event: GpsGeofenceEvent) {
|
||||
const notificationDeviceId = getSavedNotificationDeviceId().trim();
|
||||
const targetClientIds = notificationDeviceId ? [notificationDeviceId] : [];
|
||||
const targetDeviceIds = notificationDeviceId ? [notificationDeviceId] : [];
|
||||
const targetAppOrigin = typeof window !== 'undefined' ? window.location.origin.trim() : '';
|
||||
const targetAppDomain = typeof window !== 'undefined' ? window.location.hostname.trim() : '';
|
||||
|
||||
@@ -166,7 +166,7 @@ function sendGpsPushNotification(event: GpsGeofenceEvent) {
|
||||
title: `GPS 거점 ${event.type === 'enter' ? 'In' : 'Out'} 알림`,
|
||||
body: `${event.anchorName} ${event.type === 'enter' ? '진입' : '이탈'} 감지 (${event.distanceMeters}m)`,
|
||||
threadId: 'gps-geofence',
|
||||
targetClientIds,
|
||||
targetDeviceIds,
|
||||
targetAppOrigins: targetAppOrigin ? [targetAppOrigin] : undefined,
|
||||
targetAppDomains: targetAppDomain ? [targetAppDomain] : undefined,
|
||||
data: {
|
||||
|
||||
12
src/main.tsx
12
src/main.tsx
@@ -4,6 +4,7 @@ import koKR from 'antd/locale/ko_KR';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { initializeAppUpdate } from './app/main/appUpdate';
|
||||
import { applyBootstrapInstallMetadataForCurrentRoute, swapInstallDocumentMetadata } from './app/main/pwa/installManifest';
|
||||
import { setupMobileNavigationGestureBlocker } from './app/main/mobileNavigationGestureBlocker';
|
||||
import { ensurePreviewRuntimeFreshState, installPreviewRuntimeConsoleBridge, isPreviewRuntime } from './app/main/previewRuntime';
|
||||
import { installAppThemeColorSync } from './app/main/themeColorSync';
|
||||
@@ -13,7 +14,18 @@ import { AppStoreProvider } from './store';
|
||||
import './styles.css';
|
||||
|
||||
async function bootstrap() {
|
||||
let restoreBootstrapInstallMetadata = () => undefined;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const bootstrapInstallMetadata = applyBootstrapInstallMetadataForCurrentRoute();
|
||||
|
||||
if (bootstrapInstallMetadata?.manifestObjectUrl) {
|
||||
restoreBootstrapInstallMetadata = swapInstallDocumentMetadata({
|
||||
manifestHref: bootstrapInstallMetadata.manifestObjectUrl,
|
||||
title: bootstrapInstallMetadata.title,
|
||||
themeColor: bootstrapInstallMetadata.themeColor,
|
||||
});
|
||||
}
|
||||
if (isPreviewRuntime()) {
|
||||
await ensurePreviewRuntimeFreshState();
|
||||
installPreviewRuntimeConsoleBridge();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Tag } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import './AppsLibraryView.css';
|
||||
import { BaseballTicketBayPlayAppView } from '../baseball-ticket-bay/BaseballTicketBayPlayAppView';
|
||||
@@ -10,6 +10,7 @@ import { TheQuestAppView } from '../the-quest/TheQuestAppView';
|
||||
import { TetrisAppView } from '../tetris/TetrisAppView';
|
||||
import { APP_LIBRARY_ENTRIES, findReadyPlayAppEntryById } from './appsRegistry';
|
||||
import { buildPlayAppPath } from '../../../../app/main/routes';
|
||||
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../../../../app/main/pwa/installManifest';
|
||||
|
||||
function normalizeReturnToPath(returnTo: string | null) {
|
||||
if (!returnTo || !returnTo.startsWith('/') || returnTo.startsWith('//')) {
|
||||
@@ -19,6 +20,23 @@ function normalizeReturnToPath(returnTo: string | null) {
|
||||
return returnTo;
|
||||
}
|
||||
|
||||
function resolvePlayAppInstallThemeColor(appId: string) {
|
||||
switch (appId) {
|
||||
case 'baseball-ticket-bay':
|
||||
return '#1b3f91';
|
||||
case 'photoprism':
|
||||
return '#0f766e';
|
||||
case 'photo-puzzle':
|
||||
return '#d97706';
|
||||
case 'the-quest':
|
||||
return '#7c3aed';
|
||||
case 'tetris':
|
||||
return '#0f172a';
|
||||
default:
|
||||
return '#165dff';
|
||||
}
|
||||
}
|
||||
|
||||
export function AppsLibraryView() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -51,6 +69,35 @@ export function AppsLibraryView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined' || !activeAppEntry || activeAppEntry.id === 'e-reader') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startPath = `${location.pathname}${location.search}${location.hash}`;
|
||||
const manifestObjectUrl = createInstallManifestObjectUrl({
|
||||
startPath,
|
||||
scope: location.pathname,
|
||||
name: activeAppEntry.name,
|
||||
shortName: activeAppEntry.name,
|
||||
description: `${activeAppEntry.name} 앱을 홈 화면에서 바로 엽니다.`,
|
||||
themeColor: resolvePlayAppInstallThemeColor(activeAppEntry.id),
|
||||
backgroundColor: '#eff5ff',
|
||||
});
|
||||
const restoreManifest = swapInstallDocumentMetadata({
|
||||
manifestHref: manifestObjectUrl,
|
||||
title: activeAppEntry.name,
|
||||
themeColor: resolvePlayAppInstallThemeColor(activeAppEntry.id),
|
||||
});
|
||||
|
||||
return () => {
|
||||
restoreManifest();
|
||||
if (manifestObjectUrl) {
|
||||
window.URL.revokeObjectURL(manifestObjectUrl);
|
||||
}
|
||||
};
|
||||
}, [activeAppEntry, location.hash, location.pathname, location.search]);
|
||||
|
||||
const openApp = (appId: string) => {
|
||||
const currentPath = `${location.pathname}${location.search}${location.hash}`;
|
||||
setSearchParams(new URLSearchParams(buildPlayAppPath(appId, 'embedded', currentPath).split('?')[1] ?? ''));
|
||||
|
||||
@@ -355,6 +355,12 @@
|
||||
color: rgba(36, 52, 67, 0.72);
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__scope-note,
|
||||
.baseball-ticket-bay-app__log-client {
|
||||
font-size: 11px;
|
||||
color: rgba(36, 52, 67, 0.64);
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__item-schedule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -403,6 +409,10 @@
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__item--readonly {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__item-top {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
@@ -414,6 +424,12 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__log-heading {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__item-top strong,
|
||||
.baseball-ticket-bay-app__list-header strong {
|
||||
font-size: 14px;
|
||||
@@ -455,6 +471,8 @@
|
||||
|
||||
.baseball-ticket-bay-app__success-board {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-layout {
|
||||
@@ -463,6 +481,38 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-selection-toolbar,
|
||||
.baseball-ticket-bay-app__success-selection-summary,
|
||||
.baseball-ticket-bay-app__success-selection-toggle,
|
||||
.baseball-ticket-bay-app__success-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-selection-toolbar {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(81, 107, 136, 0.12);
|
||||
background: rgba(247, 250, 254, 0.88);
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-selection-toggle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #243443;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-selection-summary {
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
font-size: 12px;
|
||||
color: rgba(36, 52, 67, 0.72);
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
@@ -479,12 +529,17 @@
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
color: #243443;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-item-top-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-item.is-active {
|
||||
@@ -493,6 +548,22 @@
|
||||
box-shadow: inset 0 0 0 1px rgba(31, 103, 219, 0.12);
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-item-selection {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-item-button {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
color: #243443;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-item-top,
|
||||
.baseball-ticket-bay-app__success-detail-header {
|
||||
display: flex;
|
||||
@@ -501,6 +572,11 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-detail-copy {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-item-top strong,
|
||||
.baseball-ticket-bay-app__success-detail-header strong {
|
||||
font-size: 14px;
|
||||
@@ -530,6 +606,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-detail-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -930,6 +1010,16 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-selection-toolbar,
|
||||
.baseball-ticket-bay-app__success-selection-summary {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-selection-summary {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-layout:not(.is-detail-open) .baseball-ticket-bay-app__success-detail {
|
||||
display: none;
|
||||
}
|
||||
@@ -952,6 +1042,15 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-detail-actions {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-detail-actions .ant-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.baseball-ticket-bay-app__success-screen-actions {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SendOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Drawer, Input, InputNumber, Select, Tag, message } from 'antd';
|
||||
import { Button, Checkbox, Drawer, Input, InputNumber, Select, Tag, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
fetchWebPushConfig,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
serializePushSubscription,
|
||||
syncExistingWebPushSubscriptionRegistration,
|
||||
} from '../../../../app/main/webPushRegistration';
|
||||
import { useTokenAccess } from '../../../../app/main/tokenAccess';
|
||||
import {
|
||||
createBaseballTicketBayAlert,
|
||||
deleteBaseballTicketBayAlert,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
fetchBaseballTicketBayAlerts,
|
||||
fetchBaseballTicketBayLogs,
|
||||
runBaseballTicketBayAlert as runBaseballTicketBayAlertRequest,
|
||||
setBaseballTicketBayShareTokenOverride,
|
||||
updateBaseballTicketBayAlert,
|
||||
type BaseballTicketBayAlertItem as TicketAlertItem,
|
||||
type BaseballTicketBayAlertLogItem as AlertLogItem,
|
||||
@@ -49,6 +51,7 @@ import './BaseballTicketBayPlayAppView.css';
|
||||
type BaseballTicketBayPlayAppViewProps = {
|
||||
onBack: () => void;
|
||||
launchContext?: 'direct' | 'embedded';
|
||||
shareToken?: string | null;
|
||||
};
|
||||
|
||||
type DraftAlert = {
|
||||
@@ -73,6 +76,9 @@ type AlertLogAction = 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
|
||||
type SuccessLogRow = {
|
||||
id: string;
|
||||
sourceLogId: string;
|
||||
clientId: string;
|
||||
ownerType: 'client' | 'shared-token';
|
||||
ownerId: string;
|
||||
alertTitle: string;
|
||||
ticketTitle: string;
|
||||
eventDateTime: string;
|
||||
@@ -108,6 +114,8 @@ type PushTestDiagnostic = {
|
||||
rows: PushRegistrationStatusRow[];
|
||||
};
|
||||
|
||||
type BaseballTicketBayAccessScope = 'all' | 'client' | 'shared-token';
|
||||
|
||||
const TEAM_OPTIONS = ['전체', 'LG', '두산', 'SSG', '키움', 'KT', 'KIA', 'NC', '롯데', '삼성', '한화'].map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
@@ -387,6 +395,71 @@ function resolveLogStatusLabel(status: AlertLogItem['status']) {
|
||||
return '기록';
|
||||
}
|
||||
|
||||
function waitForPushServiceWorkerStateChange(
|
||||
worker: ServiceWorker,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
return new Promise<void>((resolve) => {
|
||||
let completed = false;
|
||||
|
||||
const finish = () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
completed = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
worker.removeEventListener('statechange', onStateChange);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onStateChange = () => {
|
||||
if (worker.state === 'activated' || worker.state === 'installed') {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(finish, timeoutMs);
|
||||
worker.addEventListener('statechange', onStateChange);
|
||||
onStateChange();
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePushServiceWorkerRegistration() {
|
||||
if (typeof window === 'undefined' || typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js';
|
||||
const registrationOptions = import.meta.env.DEV ? { scope: '/', type: 'module' as const } : { scope: '/' };
|
||||
const registration =
|
||||
(await navigator.serviceWorker.getRegistration()) ??
|
||||
(await navigator.serviceWorker.register(serviceWorkerUrl, registrationOptions));
|
||||
|
||||
if (registration.active || registration.waiting) {
|
||||
return registration;
|
||||
}
|
||||
|
||||
if (registration.installing) {
|
||||
await waitForPushServiceWorkerStateChange(registration.installing, 30_000);
|
||||
}
|
||||
|
||||
if (registration.active || registration.waiting) {
|
||||
return registration;
|
||||
}
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise<null>((resolve) => {
|
||||
window.setTimeout(() => resolve(null), 30_000);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePushRegistration() {
|
||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||
throw new Error('브라우저 환경에서만 사용할 수 있습니다.');
|
||||
@@ -408,11 +481,11 @@ async function ensurePushRegistration() {
|
||||
throw new Error('서버 Web Push 설정이 비어 있습니다.');
|
||||
}
|
||||
|
||||
const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js';
|
||||
const registration =
|
||||
(await navigator.serviceWorker.getRegistration()) ??
|
||||
(await navigator.serviceWorker.register(serviceWorkerUrl, import.meta.env.DEV ? { scope: '/', type: 'module' } : { scope: '/' })) ??
|
||||
(await navigator.serviceWorker.ready);
|
||||
const registration = await resolvePushServiceWorkerRegistration();
|
||||
|
||||
if (!registration) {
|
||||
throw new Error('서비스 워커를 준비하지 못했습니다.');
|
||||
}
|
||||
|
||||
await ensureWebPushSubscriptionRegistered(registration, {
|
||||
deviceId: getSavedNotificationDeviceId(),
|
||||
@@ -428,11 +501,7 @@ async function getCurrentPushRegistration() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js';
|
||||
const registration =
|
||||
(await navigator.serviceWorker.getRegistration()) ??
|
||||
(await navigator.serviceWorker.register(serviceWorkerUrl, import.meta.env.DEV ? { scope: '/', type: 'module' } : { scope: '/' })) ??
|
||||
(await navigator.serviceWorker.ready);
|
||||
const registration = await resolvePushServiceWorkerRegistration();
|
||||
|
||||
if (!registration) {
|
||||
return null;
|
||||
@@ -500,7 +569,7 @@ function buildPushStatusRows(args: {
|
||||
key: 'deviceId',
|
||||
label: '기기 ID',
|
||||
value: args.deviceId || '-',
|
||||
detail: args.clientId ? `clientId ${args.clientId}` : undefined,
|
||||
detail: args.clientId ? `기기 식별값 ${args.clientId}` : undefined,
|
||||
},
|
||||
{
|
||||
key: 'endpoint',
|
||||
@@ -554,12 +623,66 @@ function buildAlertSummary(item: DraftAlert | TicketAlertItem) {
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct' }: BaseballTicketBayPlayAppViewProps) {
|
||||
function formatClientIdLabel(value: string) {
|
||||
const normalized = value.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (normalized.length <= 16) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, 8)}...${normalized.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatScopeIdentifierLabel(value: string | null | undefined) {
|
||||
return formatClientIdLabel(value?.trim() ?? '');
|
||||
}
|
||||
|
||||
function formatOwnershipLabel(ownerType: 'client' | 'shared-token', ownerId: string | null | undefined, clientId: string) {
|
||||
if (ownerType === 'shared-token') {
|
||||
return `토큰 ${formatScopeIdentifierLabel(ownerId)}`;
|
||||
}
|
||||
|
||||
return `기기 ${formatClientIdLabel(clientId)}`;
|
||||
}
|
||||
|
||||
function formatAccessScopeLabel(scope: BaseballTicketBayAccessScope) {
|
||||
if (scope === 'all') {
|
||||
return '전체 보기';
|
||||
}
|
||||
|
||||
if (scope === 'shared-token') {
|
||||
return '공유토큰 기준';
|
||||
}
|
||||
|
||||
return '내 기기 기준';
|
||||
}
|
||||
|
||||
function normalizeShareToken(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function readShareTokenFromUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalizeShareToken(new URLSearchParams(window.location.search).get('shareToken'));
|
||||
}
|
||||
|
||||
export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct', shareToken }: BaseballTicketBayPlayAppViewProps) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const { hasAccess: hasGlobalAccess } = useTokenAccess();
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [draft, setDraft] = useState<DraftAlert>(() => createInitialDraft());
|
||||
const [alerts, setAlerts] = useState<TicketAlertItem[]>([]);
|
||||
const [logs, setLogs] = useState<AlertLogItem[]>([]);
|
||||
const [hasAllClientScope, setHasAllClientScope] = useState(false);
|
||||
const [accessScope, setAccessScope] = useState<BaseballTicketBayAccessScope>('client');
|
||||
const [scopeOwnerId, setScopeOwnerId] = useState<string | null>(null);
|
||||
const [isPushPending, setIsPushPending] = useState(false);
|
||||
const [pushStatusLoading, setPushStatusLoading] = useState(false);
|
||||
const [pushStatusRows, setPushStatusRows] = useState<PushRegistrationStatusRow[]>([]);
|
||||
@@ -572,8 +695,18 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
const [isLogDrawerOpen, setIsLogDrawerOpen] = useState(false);
|
||||
const [selectedLogAlertId, setSelectedLogAlertId] = useState<string | null>(null);
|
||||
const [selectedSuccessLogId, setSelectedSuccessLogId] = useState<string | null>(null);
|
||||
const [selectedSuccessRowIds, setSelectedSuccessRowIds] = useState<string[]>([]);
|
||||
const [editingAlertId, setEditingAlertId] = useState<string | null>(null);
|
||||
const isEmbeddedLaunch = launchContext === 'embedded';
|
||||
const effectiveShareToken = normalizeShareToken(shareToken) || readShareTokenFromUrl();
|
||||
|
||||
useEffect(() => {
|
||||
setBaseballTicketBayShareTokenOverride(effectiveShareToken);
|
||||
|
||||
return () => {
|
||||
setBaseballTicketBayShareTokenOverride('');
|
||||
};
|
||||
}, [effectiveShareToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedClientId = getOrCreateClientId().trim();
|
||||
@@ -582,8 +715,11 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
void (async () => {
|
||||
try {
|
||||
const [nextAlerts, nextLogs] = await Promise.all([fetchBaseballTicketBayAlerts(), fetchBaseballTicketBayLogs()]);
|
||||
setAlerts(nextAlerts);
|
||||
setLogs(nextLogs);
|
||||
setAlerts(nextAlerts.items);
|
||||
setLogs(nextLogs.items);
|
||||
setHasAllClientScope(nextAlerts.includeAllClients || nextLogs.includeAllClients);
|
||||
setAccessScope(nextAlerts.accessScope === nextLogs.accessScope ? nextAlerts.accessScope : nextAlerts.accessScope);
|
||||
setScopeOwnerId(nextAlerts.scopeOwnerId ?? nextLogs.scopeOwnerId ?? null);
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '저장된 알림을 불러오지 못했습니다.');
|
||||
}
|
||||
@@ -645,14 +781,6 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshPushStatus({ syncExistingRegistration: true });
|
||||
}, [clientId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingsOpen || !clientId) {
|
||||
return;
|
||||
@@ -694,6 +822,9 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
return item.payload!.results.map((result) => ({
|
||||
id: `${item.id}:${result.displayNumber}`,
|
||||
sourceLogId: item.id,
|
||||
clientId: item.clientId,
|
||||
ownerType: item.ownerType,
|
||||
ownerId: item.ownerId,
|
||||
alertTitle: alert.title,
|
||||
ticketTitle: result.title,
|
||||
eventDateTime: result.eventDateTime,
|
||||
@@ -724,6 +855,38 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
() => successRows.find((item) => item.id === selectedSuccessLogId) ?? successRows[0] ?? null,
|
||||
[selectedSuccessLogId, successRows],
|
||||
);
|
||||
const selectedSuccessRowIdSet = useMemo(() => new Set(selectedSuccessRowIds), [selectedSuccessRowIds]);
|
||||
const selectedSuccessRows = useMemo(
|
||||
() => successRows.filter((item) => selectedSuccessRowIdSet.has(item.id)),
|
||||
[selectedSuccessRowIdSet, successRows],
|
||||
);
|
||||
const selectedSuccessSourceLogIds = useMemo(
|
||||
() => Array.from(new Set(selectedSuccessRows.map((item) => item.sourceLogId))),
|
||||
[selectedSuccessRows],
|
||||
);
|
||||
const selectableSuccessRows = useMemo(
|
||||
() => (accessScope === 'shared-token' ? successRows : successRows.filter((item) => item.clientId === clientId)),
|
||||
[accessScope, clientId, successRows],
|
||||
);
|
||||
const isAllSuccessRowsSelected = selectableSuccessRows.length > 0 && selectedSuccessRowIds.length === selectableSuccessRows.length;
|
||||
const isSomeSuccessRowsSelected = selectedSuccessRowIds.length > 0 && selectedSuccessRowIds.length < selectableSuccessRows.length;
|
||||
const isViewingAllClients = !effectiveShareToken && (hasGlobalAccess || hasAllClientScope);
|
||||
const isSharedTokenScope = accessScope === 'shared-token';
|
||||
const scopeSummaryLabel = isSharedTokenScope ? '공유 토큰' : '접속 기기';
|
||||
const scopeSummaryValue = isSharedTokenScope
|
||||
? formatScopeIdentifierLabel(scopeOwnerId || effectiveShareToken || clientId)
|
||||
: (clientId ? clientId.slice(0, 12) : '-');
|
||||
const canManageByClientId = (targetClientId: string) => {
|
||||
if (accessScope === 'shared-token') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (accessScope === 'all') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return targetClientId === clientId;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!successRows.length) {
|
||||
@@ -738,6 +901,10 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
}
|
||||
}, [selectedSuccessLogId, successRows]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSuccessRowIds((previous) => previous.filter((rowId) => successRows.some((item) => item.id === rowId)));
|
||||
}, [successRows]);
|
||||
|
||||
const isSuccessPanel = activePanel === 'success-list' || activePanel === 'success-detail';
|
||||
|
||||
const openSuccessList = () => {
|
||||
@@ -814,6 +981,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
setLogs((previous) => [
|
||||
{
|
||||
id: createId('log'),
|
||||
clientId,
|
||||
createdAt: new Date().toISOString(),
|
||||
...item,
|
||||
},
|
||||
@@ -823,8 +991,11 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
|
||||
const reloadServerData = async () => {
|
||||
const [nextAlerts, nextLogs] = await Promise.all([fetchBaseballTicketBayAlerts(), fetchBaseballTicketBayLogs()]);
|
||||
setAlerts(nextAlerts);
|
||||
setLogs(nextLogs);
|
||||
setAlerts(nextAlerts.items);
|
||||
setLogs(nextLogs.items);
|
||||
setHasAllClientScope(nextAlerts.includeAllClients || nextLogs.includeAllClients);
|
||||
setAccessScope(nextAlerts.accessScope === nextLogs.accessScope ? nextAlerts.accessScope : nextAlerts.accessScope);
|
||||
setScopeOwnerId(nextAlerts.scopeOwnerId ?? nextLogs.scopeOwnerId ?? null);
|
||||
};
|
||||
|
||||
const runAlertNow = async (
|
||||
@@ -893,6 +1064,9 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
|
||||
const nextItem: TicketAlertItem = {
|
||||
id: editingAlertId ?? createId('alert'),
|
||||
clientId,
|
||||
ownerType: accessScope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessScope === 'shared-token' ? scopeOwnerId ?? clientId : clientId,
|
||||
title: draft.title.trim(),
|
||||
eventDate: draft.eventDate,
|
||||
team: draft.team,
|
||||
@@ -1036,6 +1210,67 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSuccessRowSelection = (rowId: string) => {
|
||||
const target = successRows.find((item) => item.id === rowId);
|
||||
|
||||
if (!target || !canManageByClientId(target.clientId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSuccessRowIds((previous) => (previous.includes(rowId) ? previous.filter((item) => item !== rowId) : [...previous, rowId]));
|
||||
};
|
||||
|
||||
const handleToggleAllSuccessRows = () => {
|
||||
setSelectedSuccessRowIds((previous) => (previous.length === selectableSuccessRows.length ? [] : selectableSuccessRows.map((item) => item.id)));
|
||||
};
|
||||
|
||||
const handleDeleteSelectedSuccessLogs = async () => {
|
||||
if (!selectedSuccessSourceLogIds.length) {
|
||||
messageApi.error('삭제할 성공 항목을 먼저 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmMessage =
|
||||
selectedSuccessSourceLogIds.length === 1
|
||||
? '선택한 성공 항목을 삭제할까요? 같은 실행에서 저장된 성공 항목이 함께 사라집니다.'
|
||||
: `선택한 성공 항목 ${selectedSuccessRowIds.length}건을 삭제할까요? ${selectedSuccessSourceLogIds.length}개 실행 로그가 함께 삭제됩니다.`;
|
||||
|
||||
if (typeof window !== 'undefined' && !window.confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(selectedSuccessSourceLogIds.map((logId) => deleteBaseballTicketBayLog(logId)));
|
||||
const successCount = results.filter((result) => result.status === 'fulfilled').length;
|
||||
const failureResults = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||
|
||||
try {
|
||||
await reloadServerData();
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '성공 로그 목록을 다시 불러오지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSuccessRowIds([]);
|
||||
|
||||
if (!failureResults.length) {
|
||||
messageApi.success(`선택한 성공 항목 ${selectedSuccessRowIds.length}건을 삭제했습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
messageApi.warning(
|
||||
`선택 삭제 중 ${successCount}개 실행 로그만 삭제했습니다. ${
|
||||
failureResults[0].reason instanceof Error ? failureResults[0].reason.message : '일부 성공 로그 삭제에 실패했습니다.'
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
messageApi.error(
|
||||
failureResults[0].reason instanceof Error ? failureResults[0].reason.message : '선택한 성공 로그 삭제에 실패했습니다.',
|
||||
);
|
||||
};
|
||||
|
||||
const handleReload = async () => {
|
||||
try {
|
||||
await reloadServerData();
|
||||
@@ -1057,10 +1292,10 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
alertTitle: '공통',
|
||||
action: 'push',
|
||||
status: 'success',
|
||||
message: '현재 클라이언트의 Web Push 등록을 완료했습니다.',
|
||||
message: '현재 기기의 Web Push 등록을 완료했습니다.',
|
||||
detail: clientId || '-',
|
||||
});
|
||||
messageApi.success('현재 클라이언트의 Web Push 등록을 완료했습니다.');
|
||||
messageApi.success('현재 기기의 Web Push 등록을 완료했습니다.');
|
||||
} catch (error) {
|
||||
appendLog({
|
||||
alertId: null,
|
||||
@@ -1078,7 +1313,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
|
||||
const handleSendTestPush = async () => {
|
||||
if (!clientId) {
|
||||
messageApi.error('clientId를 확인하지 못했습니다.');
|
||||
messageApi.error('기기 식별값을 확인하지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1263,7 +1498,12 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
<div>
|
||||
<strong>야구-티켓베이</strong>
|
||||
<div className="baseball-ticket-bay-app__meta">
|
||||
<span>clientId {clientId ? clientId.slice(0, 12) : '-'}</span>
|
||||
<span>{scopeSummaryLabel} {scopeSummaryValue}</span>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag color="gold" bordered={false}>
|
||||
{formatAccessScopeLabel(accessScope)}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag color="blue" bordered={false}>
|
||||
활성 {activeCount}
|
||||
</Tag>
|
||||
@@ -1336,35 +1576,70 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
</div>
|
||||
<div className="baseball-ticket-bay-app__success-screen-body">
|
||||
{successRows.length ? (
|
||||
<div className={`baseball-ticket-bay-app__success-layout${activePanel === 'success-detail' ? ' is-detail-open' : ''}`}>
|
||||
<div className="baseball-ticket-bay-app__success-list" role="list" aria-label="성공한 실행 목록">
|
||||
{successRows.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`baseball-ticket-bay-app__success-item${selectedSuccessRow?.id === item.id ? ' is-active' : ''}`}
|
||||
onClick={() => openSuccessDetail(item.id)}
|
||||
>
|
||||
<div className="baseball-ticket-bay-app__success-item-top">
|
||||
<strong>{item.ticketTitle}</strong>
|
||||
<Tag bordered={false} color="green">
|
||||
성공
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="baseball-ticket-bay-app__success-item-meta">
|
||||
<span>{formatDateTimeLabel(item.eventDateTime)}</span>
|
||||
<span>{item.teamName}</span>
|
||||
<span>{item.seatCount}</span>
|
||||
</div>
|
||||
<div className="baseball-ticket-bay-app__success-item-summary">{item.summary}</div>
|
||||
<div className="baseball-ticket-bay-app__success-item-time">{formatDateTimeLabel(item.createdAt)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedSuccessRow ? (
|
||||
<article className="baseball-ticket-bay-app__success-detail" aria-label="성공 티켓 상세">
|
||||
<div className="baseball-ticket-bay-app__success-board">
|
||||
{activePanel !== 'success-detail' ? (
|
||||
<div className="baseball-ticket-bay-app__success-selection-toolbar">
|
||||
<label className="baseball-ticket-bay-app__success-selection-toggle">
|
||||
<Checkbox checked={isAllSuccessRowsSelected} indeterminate={isSomeSuccessRowsSelected} onChange={handleToggleAllSuccessRows} />
|
||||
<span>전체 선택</span>
|
||||
</label>
|
||||
<div className="baseball-ticket-bay-app__success-selection-summary">
|
||||
<span>
|
||||
{selectedSuccessRowIds.length}건 선택
|
||||
{selectedSuccessSourceLogIds.length ? ` · ${selectedSuccessSourceLogIds.length}개 실행 삭제` : ''}
|
||||
</span>
|
||||
<Button danger disabled={!selectedSuccessSourceLogIds.length} icon={<DeleteOutlined />} onClick={() => void handleDeleteSelectedSuccessLogs()}>
|
||||
선택 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`baseball-ticket-bay-app__success-layout${activePanel === 'success-detail' ? ' is-detail-open' : ''}`}>
|
||||
<div className="baseball-ticket-bay-app__success-list" role="list" aria-label="성공한 실행 목록">
|
||||
{successRows.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`baseball-ticket-bay-app__success-item${selectedSuccessRow?.id === item.id ? ' is-active' : ''}`}
|
||||
>
|
||||
<div className="baseball-ticket-bay-app__success-item-selection">
|
||||
<Checkbox
|
||||
checked={selectedSuccessRowIdSet.has(item.id)}
|
||||
disabled={!canManageByClientId(item.clientId)}
|
||||
aria-label={`${item.ticketTitle} 성공 항목 선택`}
|
||||
onChange={() => handleToggleSuccessRowSelection(item.id)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="baseball-ticket-bay-app__success-item-button"
|
||||
onClick={() => openSuccessDetail(item.id)}
|
||||
>
|
||||
<div className="baseball-ticket-bay-app__success-item-top">
|
||||
<strong>{item.ticketTitle}</strong>
|
||||
<div className="baseball-ticket-bay-app__success-item-top-tags">
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag bordered={false}>{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}</Tag>
|
||||
) : null}
|
||||
<Tag bordered={false} color="green">
|
||||
성공
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div className="baseball-ticket-bay-app__success-item-meta">
|
||||
<span>{formatDateTimeLabel(item.eventDateTime)}</span>
|
||||
<span>{item.teamName}</span>
|
||||
<span>{item.seatCount}</span>
|
||||
</div>
|
||||
<div className="baseball-ticket-bay-app__success-item-summary">{item.summary}</div>
|
||||
<div className="baseball-ticket-bay-app__success-item-time">{formatDateTimeLabel(item.createdAt)}</div>
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{selectedSuccessRow ? (
|
||||
<article className="baseball-ticket-bay-app__success-detail" aria-label="성공 티켓 상세">
|
||||
<div className="baseball-ticket-bay-app__success-detail-header">
|
||||
<div>
|
||||
<div className="baseball-ticket-bay-app__success-detail-copy">
|
||||
<strong>{selectedSuccessRow.ticketTitle}</strong>
|
||||
<div className="baseball-ticket-bay-app__success-detail-subtitle">
|
||||
{formatDateTimeLabel(selectedSuccessRow.eventDateTime)} · {selectedSuccessRow.teamName}
|
||||
@@ -1374,12 +1649,18 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
<Tag bordered={false} color="green">
|
||||
성공
|
||||
</Tag>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag bordered={false}>
|
||||
{formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId)}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="성공 로그 삭제"
|
||||
title="성공 로그 삭제"
|
||||
disabled={!canManageByClientId(selectedSuccessRow.clientId)}
|
||||
onClick={() => void handleDeleteSuccessLog(selectedSuccessRow.sourceLogId)}
|
||||
>
|
||||
삭제
|
||||
@@ -1479,8 +1760,9 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
<strong>직관샷이 없어도 조건에 맞으면 성공으로 수집합니다.</strong>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="baseball-ticket-bay-app__empty">성공 로그가 아직 없습니다.</div>
|
||||
@@ -1759,15 +2041,31 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
<span>{alerts.length}건</span>
|
||||
</div>
|
||||
</div>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<div className="baseball-ticket-bay-app__scope-note">
|
||||
{isSharedTokenScope ? '현재 공유 토큰 기준으로 목록을 표시합니다.' : '등록 토큰으로 전체 기기 목록을 보고 있습니다. 수정과 삭제는 현재 기기 항목만 가능합니다.'}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="baseball-ticket-bay-app__items">
|
||||
{alerts.length ? (
|
||||
alerts.map((item) => (
|
||||
<article key={item.id} className={`baseball-ticket-bay-app__item${item.active ? '' : ' baseball-ticket-bay-app__item--paused'}`}>
|
||||
alerts.map((item) => {
|
||||
const isOwnedByCurrentClient = canManageByClientId(item.clientId);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`baseball-ticket-bay-app__item${item.active ? '' : ' baseball-ticket-bay-app__item--paused'}${!isOwnedByCurrentClient ? ' baseball-ticket-bay-app__item--readonly' : ''}`}
|
||||
>
|
||||
<div className="baseball-ticket-bay-app__item-top">
|
||||
<div className="baseball-ticket-bay-app__item-heading">
|
||||
<strong>{item.title}</strong>
|
||||
<div className="baseball-ticket-bay-app__item-date">{formatDateLabel(item.eventDate)}</div>
|
||||
<div className="baseball-ticket-bay-app__item-tags">
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag bordered={false} color={isOwnedByCurrentClient ? 'blue' : 'default'}>
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag bordered={false} color={item.active ? 'green' : 'default'}>
|
||||
{item.active ? '실행중' : '중지'}
|
||||
</Tag>
|
||||
@@ -1788,12 +2086,13 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
</div>
|
||||
</div>
|
||||
<div className="baseball-ticket-bay-app__item-actions">
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEditAlert(item.id)}>
|
||||
<Button type="text" icon={<EditOutlined />} disabled={!isOwnedByCurrentClient} onClick={() => handleEditAlert(item.id)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SendOutlined />}
|
||||
disabled={!isOwnedByCurrentClient}
|
||||
loading={isImmediateRunPending}
|
||||
onClick={() => void runAlertNow(item, 'run', { openLogsOnComplete: true })}
|
||||
/>
|
||||
@@ -1808,9 +2107,10 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
<Button
|
||||
type="text"
|
||||
icon={item.active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
disabled={!isOwnedByCurrentClient}
|
||||
onClick={() => handleToggleAlert(item.id)}
|
||||
/>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDeleteAlert(item.id)} />
|
||||
<Button type="text" danger icon={<DeleteOutlined />} disabled={!isOwnedByCurrentClient} onClick={() => handleDeleteAlert(item.id)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="baseball-ticket-bay-app__item-summary">{buildAlertSummary(item)}</div>
|
||||
@@ -1824,7 +2124,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))
|
||||
)})
|
||||
) : (
|
||||
<div className="baseball-ticket-bay-app__empty">저장된 알림이 없습니다.</div>
|
||||
)}
|
||||
@@ -1864,7 +2164,14 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
visibleLogs.map((item) => (
|
||||
<article key={item.id} className="baseball-ticket-bay-app__log-item">
|
||||
<div className="baseball-ticket-bay-app__log-top">
|
||||
<strong>{item.alertTitle}</strong>
|
||||
<div className="baseball-ticket-bay-app__log-heading">
|
||||
<strong>{item.alertTitle}</strong>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<span className="baseball-ticket-bay-app__log-client">
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="baseball-ticket-bay-app__log-top-actions">
|
||||
<Tag bordered={false} color={resolveLogTagColor(item.status)}>
|
||||
{resolveLogStatusLabel(item.status)}
|
||||
@@ -1877,6 +2184,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="성공 로그 삭제"
|
||||
title="성공 로그 삭제"
|
||||
disabled={!canManageByClientId(item.clientId)}
|
||||
onClick={() => void handleDeleteSuccessLog(item.id)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1904,8 +2212,8 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
|
||||
>
|
||||
<div className="baseball-ticket-bay-app__settings">
|
||||
<div className="baseball-ticket-bay-app__settings-block">
|
||||
<span>기기 정보</span>
|
||||
<strong>{clientId || '-'}</strong>
|
||||
<span>{isSharedTokenScope ? '공유 토큰' : '접속 기기'}</span>
|
||||
<strong>{isSharedTokenScope ? formatScopeIdentifierLabel(scopeOwnerId || effectiveShareToken || clientId) : (clientId || '-')}</strong>
|
||||
</div>
|
||||
<div className="baseball-ticket-bay-app__settings-block">
|
||||
<div className="baseball-ticket-bay-app__settings-block-header">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess';
|
||||
|
||||
const WORK_SERVER_TIMEOUT_MS = 15_000;
|
||||
const BASEBALL_TICKET_BAY_RUN_TIMEOUT_MS = 180_000;
|
||||
let baseballTicketBayShareTokenOverride = '';
|
||||
|
||||
export type BaseballTicketBayTimeWindow = {
|
||||
id: string;
|
||||
@@ -12,6 +13,9 @@ export type BaseballTicketBayTimeWindow = {
|
||||
|
||||
export type BaseballTicketBayAlertItem = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
ownerType: 'client' | 'shared-token';
|
||||
ownerId: string;
|
||||
title: string;
|
||||
eventDate: string;
|
||||
team: string;
|
||||
@@ -85,6 +89,9 @@ export type BaseballTicketBayRunPayload = {
|
||||
|
||||
export type BaseballTicketBayAlertLogItem = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
ownerType: 'client' | 'shared-token';
|
||||
ownerId: string;
|
||||
alertId: string | null;
|
||||
alertTitle: string;
|
||||
action: 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
|
||||
@@ -97,19 +104,39 @@ export type BaseballTicketBayAlertLogItem = {
|
||||
|
||||
type BaseballTicketBayAlertMutation = Omit<
|
||||
BaseballTicketBayAlertItem,
|
||||
'id' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
|
||||
'id' | 'clientId' | 'ownerType' | 'ownerId' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
|
||||
>;
|
||||
|
||||
type BaseballTicketBayAlertsResponse = {
|
||||
ok: boolean;
|
||||
includeAllClients?: boolean;
|
||||
accessScope?: 'all' | 'client' | 'shared-token';
|
||||
scopeOwnerId?: string | null;
|
||||
items: BaseballTicketBayAlertItem[];
|
||||
};
|
||||
|
||||
type BaseballTicketBayLogsResponse = {
|
||||
ok: boolean;
|
||||
includeAllClients?: boolean;
|
||||
accessScope?: 'all' | 'client' | 'shared-token';
|
||||
scopeOwnerId?: string | null;
|
||||
items: BaseballTicketBayAlertLogItem[];
|
||||
};
|
||||
|
||||
export type BaseballTicketBayAlertsResult = {
|
||||
items: BaseballTicketBayAlertItem[];
|
||||
includeAllClients: boolean;
|
||||
accessScope: 'all' | 'client' | 'shared-token';
|
||||
scopeOwnerId: string | null;
|
||||
};
|
||||
|
||||
export type BaseballTicketBayLogsResult = {
|
||||
items: BaseballTicketBayAlertLogItem[];
|
||||
includeAllClients: boolean;
|
||||
accessScope: 'all' | 'client' | 'shared-token';
|
||||
scopeOwnerId: string | null;
|
||||
};
|
||||
|
||||
type BaseballTicketBayAlertResponse = {
|
||||
ok: boolean;
|
||||
item: BaseballTicketBayAlertItem;
|
||||
@@ -133,16 +160,38 @@ function resolveApiBaseUrl() {
|
||||
|
||||
const API_BASE_URL = resolveApiBaseUrl();
|
||||
|
||||
function normalizeShareToken(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function readShareTokenFromUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalizeShareToken(new URLSearchParams(window.location.search).get('shareToken'));
|
||||
}
|
||||
|
||||
function resolveBaseballTicketBayShareToken() {
|
||||
return normalizeShareToken(baseballTicketBayShareTokenOverride) || readShareTokenFromUrl();
|
||||
}
|
||||
|
||||
export function setBaseballTicketBayShareTokenOverride(shareToken: string | null | undefined) {
|
||||
baseballTicketBayShareTokenOverride = normalizeShareToken(shareToken);
|
||||
}
|
||||
|
||||
function buildHeaders(headersInit?: HeadersInit, hasJsonBody = false) {
|
||||
const headers = appendClientIdHeader(headersInit);
|
||||
const token = getRegisteredAccessToken();
|
||||
const registeredToken = getRegisteredAccessToken();
|
||||
const shareToken = resolveBaseballTicketBayShareToken();
|
||||
const accessToken = shareToken || registeredToken;
|
||||
|
||||
if (hasJsonBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
if (accessToken && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', accessToken);
|
||||
}
|
||||
|
||||
return headers;
|
||||
@@ -194,7 +243,12 @@ async function request<T>(path: string, init?: (RequestInit & { timeoutMs?: numb
|
||||
|
||||
export async function fetchBaseballTicketBayAlerts() {
|
||||
const response = await request<BaseballTicketBayAlertsResponse>('/baseball-ticket-bay/alerts');
|
||||
return response.items;
|
||||
return {
|
||||
items: response.items,
|
||||
includeAllClients: response.includeAllClients === true,
|
||||
accessScope: response.accessScope === 'all' || response.accessScope === 'shared-token' ? response.accessScope : 'client',
|
||||
scopeOwnerId: typeof response.scopeOwnerId === 'string' && response.scopeOwnerId.trim() ? response.scopeOwnerId.trim() : null,
|
||||
} satisfies BaseballTicketBayAlertsResult;
|
||||
}
|
||||
|
||||
export async function fetchBaseballTicketBayLogs(alertId?: string | null) {
|
||||
@@ -206,7 +260,12 @@ export async function fetchBaseballTicketBayLogs(alertId?: string | null) {
|
||||
|
||||
const path = params.size > 0 ? `/baseball-ticket-bay/logs?${params.toString()}` : '/baseball-ticket-bay/logs';
|
||||
const response = await request<BaseballTicketBayLogsResponse>(path);
|
||||
return response.items;
|
||||
return {
|
||||
items: response.items,
|
||||
includeAllClients: response.includeAllClients === true,
|
||||
accessScope: response.accessScope === 'all' || response.accessScope === 'shared-token' ? response.accessScope : 'client',
|
||||
scopeOwnerId: typeof response.scopeOwnerId === 'string' && response.scopeOwnerId.trim() ? response.scopeOwnerId.trim() : null,
|
||||
} satisfies BaseballTicketBayLogsResult;
|
||||
}
|
||||
|
||||
export async function deleteBaseballTicketBayLog(logId: string) {
|
||||
|
||||
@@ -1048,6 +1048,7 @@ async function createEReaderManifestObjectUrl(options?: {
|
||||
startUrl.searchParams.set('shareToken', shareToken);
|
||||
}
|
||||
|
||||
manifest.scope = startUrl.pathname;
|
||||
manifest.start_url = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`;
|
||||
|
||||
return window.URL.createObjectURL(
|
||||
|
||||
@@ -207,8 +207,13 @@
|
||||
display: grid;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.photoprism-app__login-card {
|
||||
@@ -221,6 +226,18 @@
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
@media (max-height: 760px) {
|
||||
.photoprism-app__login-shell {
|
||||
align-content: start;
|
||||
justify-items: stretch;
|
||||
padding-block: 16px 24px;
|
||||
}
|
||||
|
||||
.photoprism-app__login-card {
|
||||
margin-block: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.photoprism-app__login-card .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ function formatMemoTimestamp(value: string) {
|
||||
}
|
||||
|
||||
function getPreviewText(body: string) {
|
||||
const preview = body.replace(/\s+/g, ' ').trim();
|
||||
const [firstLine = ''] = body.split(/\r?\n/, 1);
|
||||
const preview = firstLine.trim();
|
||||
return preview || '새 메모';
|
||||
}
|
||||
|
||||
@@ -121,12 +122,13 @@ function restoreMemoShellScroll(target?: EventTarget | null) {
|
||||
|
||||
export type TextMemoWidgetProps = {
|
||||
cardWrapper?: boolean;
|
||||
headerless?: boolean;
|
||||
skin?: 'flat' | 'note';
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(function TextMemoWidget(
|
||||
{ cardWrapper, skin = 'note', title = '메모 본문' },
|
||||
{ cardWrapper, headerless = false, skin = 'note', title = '메모 본문' },
|
||||
ref,
|
||||
) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
@@ -138,7 +140,6 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
const [isEditing, setIsEditing] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const textAreaRef = useRef<TextAreaRef | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -201,25 +202,6 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInputFocused || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let frameId = 0;
|
||||
|
||||
const syncScroll = () => {
|
||||
restoreMemoShellScroll(textAreaRef.current?.resizableTextArea?.textArea ?? null);
|
||||
frameId = window.requestAnimationFrame(syncScroll);
|
||||
};
|
||||
|
||||
frameId = window.requestAnimationFrame(syncScroll);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [isInputFocused]);
|
||||
|
||||
const selectedIndex = useMemo(() => {
|
||||
if (!selectedId) {
|
||||
return -1;
|
||||
@@ -378,7 +360,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
{contextHolder}
|
||||
{modalContextHolder}
|
||||
<div className={`text-memo-widget${isFlat ? ' text-memo-widget--flat' : ''}`}>
|
||||
{isFlat ? (
|
||||
{isFlat && !headerless ? (
|
||||
<div className="text-memo-widget__head">
|
||||
<span className="text-memo-widget__head-title">{title}</span>
|
||||
<span className="text-memo-widget__head-meta">{selectedNote ? '선택 항목 편집' : '새 항목 작성'}</span>
|
||||
@@ -511,15 +493,10 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
|
||||
if (!selectedNote) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
setIsInputFocused(true);
|
||||
window.requestAnimationFrame(() => {
|
||||
restoreMemoShellScroll();
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsInputFocused(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user