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,