chore: test deploy snapshot

This commit is contained in:
2026-05-27 10:43:01 +09:00
parent c1d0f4c1db
commit 4c4b3c8d2c
78 changed files with 10392 additions and 2301 deletions

View File

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

View File

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

View File

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

View File

@@ -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 복사 후 브라우저에서 열어 주세요.');
});
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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') {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

@@ -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] ?? ''));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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