chore: test deploy snapshot

This commit is contained in:
2026-05-28 19:44:56 +09:00
parent c7f29bdc33
commit 753fd423db
6 changed files with 1461 additions and 188 deletions

View File

@@ -1279,6 +1279,8 @@ async function buildChatShareSnapshot(
: await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds);
const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null;
const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages);
const oldestLoadedMessageId = detailPage?.oldestLoadedMessageId ?? null;
const hasOlderMessages = detailPage?.hasOlderMessages ?? false;
if (tokenPayload.kind === 'prompt' && !promptTarget) {
return null;
@@ -1293,6 +1295,8 @@ async function buildChatShareSnapshot(
messages: scopedMessages,
activityLogs,
roomRequestCounts,
oldestLoadedMessageId,
hasOlderMessages,
promptTarget,
detailLevel,
} satisfies {
@@ -1307,6 +1311,8 @@ async function buildChatShareSnapshot(
processingCount: number;
unansweredCount: number;
} | undefined;
oldestLoadedMessageId: number | null;
hasOlderMessages: boolean;
promptTarget:
| {
sourceMessageId: number;
@@ -2580,6 +2586,8 @@ export async function registerChatRoutes(app: FastifyInstance) {
messages: shareSnapshot.messages,
activityLogs: shareSnapshot.activityLogs,
roomRequestCounts: shareSnapshot.roomRequestCounts,
oldestLoadedMessageId: shareSnapshot.oldestLoadedMessageId,
hasOlderMessages: shareSnapshot.hasOlderMessages,
rooms: resolvedRoomContext.rooms,
activeSessionId: activeRoom.sessionId,
promptTarget: shareSnapshot.promptTarget,
@@ -2588,6 +2596,72 @@ export async function registerChatRoutes(app: FastifyInstance) {
};
});
app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token/conversations/:sessionId`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const query = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
beforeMessageId: z.coerce.number().int().positive().optional(),
}).parse(request.query ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: params.sessionId,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const item = await getChatConversation(resolvedRoomContext.activeRoom.sessionId, null);
if (!item) {
return reply.code(404).send({
message: '채팅방을 찾을 수 없습니다.',
});
}
const detailPage = await listChatConversationDetailPage(resolvedRoomContext.activeRoom.sessionId, {
limit: query.limit ?? 40,
beforeMessageId: query.beforeMessageId ?? null,
});
return {
ok: true,
item,
messages: detailPage.messages,
requests: detailPage.requests,
activityLogs: detailPage.activityLogs,
oldestLoadedMessageId: detailPage.oldestLoadedMessageId,
hasOlderMessages: detailPage.hasOlderMessages,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/clear`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),

View File

@@ -1,10 +1,17 @@
.shared-app-settings-page {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 16px;
padding: 16px;
min-height: 100%;
background: #f7f8fb;
width: 100%;
height: 100%;
min-height: 0;
padding: 16px 18px 20px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
background: #f8fafc;
}
.shared-app-settings-page--loading {
@@ -12,18 +19,128 @@
justify-content: center;
}
.shared-app-settings-page__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.shared-app-settings-page .ant-card {
border-radius: 16px;
}
.shared-app-settings-page .ant-card-body {
.shared-app-settings-page__header {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.shared-app-settings-page__header .ant-typography {
margin-bottom: 0;
}
.shared-app-settings-page__header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.shared-app-settings-page__tabs .ant-tabs-nav,
.shared-app-settings-page__subtabs .ant-tabs-nav {
margin-bottom: 12px;
}
.shared-app-settings-page__panel {
display: grid;
gap: 14px;
}
.shared-app-settings-page__section {
display: grid;
gap: 12px;
padding: 14px 16px;
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 16px;
background: #fff;
}
.shared-app-settings-page__section-head {
display: grid;
gap: 4px;
}
.shared-app-settings-page__field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px 12px;
}
.shared-app-settings-page__field-grid .ant-form-item,
.shared-app-settings-page__check-grid .ant-form-item {
margin-bottom: 0;
}
.shared-app-settings-page__check-grid {
display: grid;
gap: 10px;
}
.shared-app-settings-page__status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.shared-app-settings-page__status-item {
display: grid;
gap: 4px;
padding: 12px 14px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid rgba(226, 232, 240, 0.92);
}
.shared-app-settings-page__status-item span {
font-size: 12px;
color: #64748b;
}
.shared-app-settings-page__status-item strong {
font-size: 14px;
color: #0f172a;
}
.shared-app-settings-page__inline-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.shared-app-settings-page__storage-list {
display: grid;
gap: 12px;
}
.shared-app-settings-page__storage-item {
display: grid;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(226, 232, 240, 0.92);
background: #f8fafc;
}
.shared-app-settings-page__storage-head {
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.shared-app-settings-page__storage-head > div {
display: grid;
gap: 4px;
}
@media (max-width: 767px) {
.shared-app-settings-page {
padding: 14px;
}
.shared-app-settings-page__section,
.shared-app-settings-page__storage-item {
padding: 12px;
}
}

View File

@@ -1,28 +1,323 @@
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Checkbox, Flex, Form, Input, InputNumber, Select, Space, Spin, Typography } from 'antd';
import { useCallback, useEffect, useState } from 'react';
import { Alert, App, Button, Checkbox, Form, Input, InputNumber, Spin, Tabs, Typography } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { fetchWebPushConfig } from './notificationApi';
import { NOTIFICATION_DEVICE_ID_STORAGE_KEY, getSavedNotificationDeviceId } from './notificationIdentity';
import {
DEFAULT_APP_CONFIG,
getWeeklyScheduleOptions,
APP_CONFIG_STORAGE_KEY,
saveAppConfigToServer,
type AppConfig,
type PlanCostTimeUnit,
} from './appConfig';
import {
TOKEN_ACCESS_STORAGE_KEY,
} from './tokenAccess';
import {
clearWebPushSubscriptionRegistration,
ensureWebPushSubscriptionRegistered,
syncExistingWebPushSubscriptionRegistration,
} from './webPushRegistration';
import { isPreviewRuntime } from './previewRuntime';
import './SharedAppSettingsPage.css';
const { Paragraph, Text, Title } = Typography;
const PLAN_COST_TIME_UNIT_OPTIONS: Array<{ value: PlanCostTimeUnit; label: string }> = [
{ value: 'hour', label: '시간' },
{ value: 'minute', label: '분' },
{ value: 'second', label: '초' },
];
const WEB_PUSH_METADATA_STORAGE_KEY = 'work-server.web-push.registration-meta.v1';
const SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-share-immediate-send-pinned-by-token';
const SHARE_LAST_ROOM_STORAGE_KEY = 'codex-live-share-last-room-by-token';
const SHARE_APP_LAUNCH_USAGE_STORAGE_KEY = 'chat-share-page:app-launch-usage:v1';
const CHAT_SHARE_ACCESS_PIN_STORAGE_KEY = 'main-chat-panel:share-access-pins';
const CHAT_CONTEXT_CONFIRM_SUPPRESSION_STORAGE_KEY = 'codex-live-context-confirm-suppressed-by-session';
const PREVIEW_RUNTIME_TOKEN_STORAGE_KEY = 'work-app.preview-runtime.registered-token';
type SharedAppSettingsPageProps = {
shareToken: string;
};
type SharedAppSettingsFormValue = AppConfig;
type ClientNotificationPermissionState = 'granted' | 'denied' | 'default' | 'unsupported';
type StorageScope = 'local' | 'session';
type StorageEntry = {
id: string;
label: string;
description: string;
scope: StorageScope;
storageKey: string;
};
type StorageGroup = {
key: string;
label: string;
description: string;
entries: StorageEntry[];
};
const STORAGE_GROUPS: StorageGroup[] = [
{
key: 'share-chat',
label: '공유채팅',
description: '공유채팅 화면에서 쓰는 클라이언트 저장값입니다.',
entries: [
{
id: 'share-last-room',
label: '마지막 채팅방',
description: '토큰별 마지막으로 열었던 채팅방 세션을 기억합니다.',
scope: 'local',
storageKey: SHARE_LAST_ROOM_STORAGE_KEY,
},
{
id: 'share-immediate-send',
label: '즉시전송 고정',
description: '토큰별 즉시전송 고정 상태를 저장합니다.',
scope: 'local',
storageKey: SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY,
},
{
id: 'share-app-usage',
label: '앱 실행 우선순위',
description: '공유채팅 안에서 자주 실행한 앱 사용량을 기록합니다.',
scope: 'local',
storageKey: SHARE_APP_LAUNCH_USAGE_STORAGE_KEY,
},
{
id: 'share-access-pin',
label: '공유 비밀번호 캐시',
description: '방별 접근 비밀번호 입력 이력을 저장합니다.',
scope: 'local',
storageKey: CHAT_SHARE_ACCESS_PIN_STORAGE_KEY,
},
{
id: 'context-confirm',
label: '문맥 확인 숨김',
description: '세션별 문맥 확인 안내를 다시 보지 않도록 저장합니다.',
scope: 'local',
storageKey: CHAT_CONTEXT_CONFIRM_SUPPRESSION_STORAGE_KEY,
},
],
},
{
key: 'notifications',
label: '알림 / PWA',
description: '웹 푸시와 기기 등록에 쓰는 저장값입니다.',
entries: [
{
id: 'notification-device-id',
label: '알림 기기 ID',
description: '현재 브라우저를 식별하는 웹 푸시 기기 ID입니다.',
scope: 'local',
storageKey: NOTIFICATION_DEVICE_ID_STORAGE_KEY,
},
{
id: 'web-push-meta',
label: '웹 푸시 등록 메타',
description: '최근 웹 푸시 등록 상태와 메타데이터를 저장합니다.',
scope: 'local',
storageKey: WEB_PUSH_METADATA_STORAGE_KEY,
},
],
},
{
key: 'access',
label: '접근 토큰',
description: '앱 접근과 등록 토큰에 쓰는 저장값입니다.',
entries: [
{
id: 'registered-token',
label: '등록 토큰',
description: '앱 접근 허용 토큰을 로컬에 저장합니다.',
scope: 'local',
storageKey: TOKEN_ACCESS_STORAGE_KEY,
},
{
id: 'preview-token',
label: '미리보기 토큰',
description: 'preview 런타임 등록 토큰을 저장합니다.',
scope: 'local',
storageKey: PREVIEW_RUNTIME_TOKEN_STORAGE_KEY,
},
{
id: 'app-config',
label: '앱 설정 캐시',
description: '서버에서 받은 앱 설정의 마지막 캐시입니다.',
scope: 'local',
storageKey: APP_CONFIG_STORAGE_KEY,
},
],
},
];
function getClientNotificationPermission(): ClientNotificationPermissionState {
if (
typeof window === 'undefined'
|| typeof Notification === 'undefined'
|| typeof navigator === 'undefined'
|| !('serviceWorker' in navigator)
|| !('PushManager' in window)
) {
return 'unsupported';
}
if (Notification.permission === 'granted') {
return 'granted';
}
if (Notification.permission === 'denied') {
return 'denied';
}
return 'default';
}
function getClientNotificationPermissionLabel(permission: ClientNotificationPermissionState) {
switch (permission) {
case 'granted':
return '허용';
case 'denied':
return '차단';
case 'unsupported':
return '미지원';
default:
return '확인 필요';
}
}
function hasSecureOrigin() {
if (typeof window === 'undefined') {
return false;
}
return window.isSecureContext || window.location.hostname === 'localhost';
}
function isStandaloneDisplayMode() {
if (typeof window === 'undefined') {
return false;
}
return (
window.matchMedia?.('(display-mode: standalone)').matches === true ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function isAppleMobileDevice() {
if (typeof navigator === 'undefined') {
return false;
}
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
}
async function getPushServiceWorkerRegistration() {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return null;
}
const resolveUsableRegistration = async (registration: ServiceWorkerRegistration | null | undefined) => {
if (!registration) {
return null;
}
if (registration.active || registration.waiting) {
return registration;
}
const installingWorker = registration.installing;
if (!installingWorker) {
return registration;
}
await new Promise<void>((resolve) => {
let completed = false;
const finish = () => {
if (completed) {
return;
}
completed = true;
window.clearTimeout(timeoutId);
installingWorker.removeEventListener('statechange', onStateChange);
resolve();
};
const onStateChange = () => {
if (
installingWorker.state === 'activated'
|| installingWorker.state === 'installed'
|| Boolean(registration.active)
|| Boolean(registration.waiting)
) {
finish();
}
};
const timeoutId = window.setTimeout(finish, 5000);
installingWorker.addEventListener('statechange', onStateChange);
onStateChange();
});
return registration;
};
const existing = await navigator.serviceWorker.getRegistration();
if (existing) {
return (await resolveUsableRegistration(existing)) ?? existing;
}
const scoped = await navigator.serviceWorker.getRegistration('/');
if (scoped) {
return (await resolveUsableRegistration(scoped)) ?? scoped;
}
const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js';
const resolvedServiceWorkerUrl =
typeof window !== 'undefined' ? new URL(serviceWorkerUrl, window.location.href).toString() : serviceWorkerUrl;
try {
const registration = import.meta.env.DEV
? await navigator.serviceWorker.register(resolvedServiceWorkerUrl, { type: 'module', scope: '/', updateViaCache: 'none' })
.catch(() => navigator.serviceWorker.register(resolvedServiceWorkerUrl, { scope: '/', updateViaCache: 'none' }))
: await navigator.serviceWorker.register(resolvedServiceWorkerUrl);
return (await resolveUsableRegistration(registration)) ?? registration;
} catch {
return null;
}
}
function readStorageValue(scope: StorageScope, key: string) {
if (typeof window === 'undefined') {
return '';
}
try {
const storage = scope === 'local' ? window.localStorage : window.sessionStorage;
return storage.getItem(key) ?? '';
} catch {
return '';
}
}
function writeStorageValue(scope: StorageScope, key: string, value: string) {
if (typeof window === 'undefined') {
return;
}
const storage = scope === 'local' ? window.localStorage : window.sessionStorage;
if (!value.trim()) {
storage.removeItem(key);
return;
}
storage.setItem(key, value);
}
function buildStorageItemKey(entry: StorageEntry) {
return `${entry.scope}:${entry.storageKey}`;
}
export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps) {
const { message } = App.useApp();
@@ -31,6 +326,64 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [savedConfig, setSavedConfig] = useState<AppConfig>(DEFAULT_APP_CONFIG);
const [activeTabKey, setActiveTabKey] = useState('general');
const [storageGroupKey, setStorageGroupKey] = useState(STORAGE_GROUPS[0]?.key ?? 'share-chat');
const [storageDrafts, setStorageDrafts] = useState<Record<string, string>>({});
const [storageFeedback, setStorageFeedback] = useState('');
const [webPushConfigured, setWebPushConfigured] = useState(false);
const [webPushRegistered, setWebPushRegistered] = useState(false);
const [webPushLoading, setWebPushLoading] = useState(false);
const [clientPermission, setClientPermission] = useState<ClientNotificationPermissionState>(() => getClientNotificationPermission());
const selectedStorageGroup = useMemo(
() => STORAGE_GROUPS.find((group) => group.key === storageGroupKey) ?? STORAGE_GROUPS[0],
[storageGroupKey],
);
const loadStorageDrafts = useCallback(() => {
const nextDrafts = STORAGE_GROUPS.flatMap((group) => group.entries).reduce<Record<string, string>>((accumulator, entry) => {
accumulator[buildStorageItemKey(entry)] = readStorageValue(entry.scope, entry.storageKey);
return accumulator;
}, {});
setStorageDrafts(nextDrafts);
}, []);
const syncWebPushStatus = useCallback(async () => {
setClientPermission(getClientNotificationPermission());
try {
const config = await fetchWebPushConfig();
const isConfigured = Boolean(config.enabled && config.publicKey);
setWebPushConfigured(isConfigured);
if (!isConfigured) {
setWebPushRegistered(false);
return;
}
const registration = await getPushServiceWorkerRegistration();
if (!registration || typeof Notification === 'undefined' || Notification.permission !== 'granted') {
setWebPushRegistered(false);
return;
}
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
setWebPushRegistered(false);
return;
}
await syncExistingWebPushSubscriptionRegistration(registration, {
deviceId: getSavedNotificationDeviceId(),
});
setWebPushRegistered(true);
} catch {
setWebPushConfigured(false);
setWebPushRegistered(false);
}
}, []);
const loadConfig = useCallback(async () => {
setIsLoading(true);
@@ -53,12 +406,14 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
const nextConfig = payload.config ?? DEFAULT_APP_CONFIG;
setSavedConfig(nextConfig);
form.setFieldsValue(nextConfig);
await syncWebPushStatus();
loadStorageDrafts();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '앱 설정을 불러오지 못했습니다.');
} finally {
setIsLoading(false);
}
}, [form, shareToken]);
}, [form, loadStorageDrafts, shareToken, syncWebPushStatus]);
useEffect(() => {
void loadConfig();
@@ -76,7 +431,7 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
});
setSavedConfig(saved);
form.setFieldsValue(saved);
message.success('설정을 저장했습니다.');
message.success('개인설정을 저장했습니다.');
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.');
} finally {
@@ -86,6 +441,145 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
[form, message, shareToken],
);
const handleRequestNotificationPermission = useCallback(async () => {
if (typeof Notification === 'undefined') {
message.warning('현재 브라우저에서는 알림 권한 요청을 지원하지 않습니다.');
return;
}
if (!hasSecureOrigin()) {
message.warning('알림은 HTTPS 또는 localhost 환경에서만 권한 요청이 가능합니다.');
return;
}
if (isAppleMobileDevice() && !isStandaloneDisplayMode()) {
message.warning('아이폰에서는 홈 화면에 추가한 PWA에서만 웹 푸시를 허용할 수 있습니다.');
return;
}
try {
await Notification.requestPermission();
await syncWebPushStatus();
message.success('브라우저 알림 권한 상태를 다시 확인했습니다.');
} catch {
message.error('알림 권한 요청 중 오류가 발생했습니다.');
}
}, [message, syncWebPushStatus]);
const handleRegisterWebPush = useCallback(async () => {
if (isPreviewRuntime()) {
message.warning('미리보기 런타임에서는 웹 푸시 등록을 지원하지 않습니다.');
return;
}
if (!hasSecureOrigin()) {
message.warning('웹 푸시는 HTTPS 또는 localhost 환경에서만 등록할 수 있습니다.');
return;
}
if (isAppleMobileDevice() && !isStandaloneDisplayMode()) {
message.warning('아이폰에서는 홈 화면에 추가한 PWA로 연 뒤 등록해 주세요.');
return;
}
setWebPushLoading(true);
try {
let permission = getClientNotificationPermission();
if (permission !== 'granted' && typeof Notification !== 'undefined') {
await Notification.requestPermission();
permission = getClientNotificationPermission();
}
if (permission !== 'granted') {
throw new Error('브라우저 알림 권한을 허용한 뒤 다시 시도해 주세요.');
}
const registration = await getPushServiceWorkerRegistration();
if (!registration) {
throw new Error('서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.');
}
await ensureWebPushSubscriptionRegistered(registration, {
deviceId: getSavedNotificationDeviceId(),
});
await syncWebPushStatus();
loadStorageDrafts();
message.success('이 기기의 웹 푸시 등록을 완료했습니다.');
} catch (error) {
message.error(error instanceof Error ? error.message : '웹 푸시 등록에 실패했습니다.');
} finally {
setWebPushLoading(false);
}
}, [loadStorageDrafts, message, syncWebPushStatus]);
const handleUnregisterWebPush = useCallback(async () => {
setWebPushLoading(true);
try {
const registration = await getPushServiceWorkerRegistration();
if (!registration) {
throw new Error('해제할 서비스워커 등록을 찾지 못했습니다.');
}
await clearWebPushSubscriptionRegistration(registration);
await syncWebPushStatus();
loadStorageDrafts();
message.success('이 기기의 웹 푸시 등록을 해제했습니다.');
} catch (error) {
message.error(error instanceof Error ? error.message : '웹 푸시 해제에 실패했습니다.');
} finally {
setWebPushLoading(false);
}
}, [loadStorageDrafts, message, syncWebPushStatus]);
const handleStorageDraftChange = useCallback((entry: StorageEntry, value: string) => {
setStorageDrafts((current) => ({
...current,
[buildStorageItemKey(entry)]: value,
}));
}, []);
const handleReloadStorageEntry = useCallback((entry: StorageEntry) => {
setStorageFeedback('');
setStorageDrafts((current) => ({
...current,
[buildStorageItemKey(entry)]: readStorageValue(entry.scope, entry.storageKey),
}));
}, []);
const handleSaveStorageEntry = useCallback((entry: StorageEntry) => {
try {
const draftValue = storageDrafts[buildStorageItemKey(entry)] ?? '';
writeStorageValue(entry.scope, entry.storageKey, draftValue);
setStorageFeedback(`${entry.label} 저장값을 반영했습니다.`);
message.success(`${entry.label} 저장값을 반영했습니다.`);
} catch (error) {
const nextMessage = error instanceof Error ? error.message : `${entry.label} 저장값을 반영하지 못했습니다.`;
setStorageFeedback(nextMessage);
message.error(nextMessage);
}
}, [message, storageDrafts]);
const handleClearStorageEntry = useCallback((entry: StorageEntry) => {
try {
writeStorageValue(entry.scope, entry.storageKey, '');
setStorageDrafts((current) => ({
...current,
[buildStorageItemKey(entry)]: '',
}));
setStorageFeedback(`${entry.label} 저장값을 비웠습니다.`);
message.success(`${entry.label} 저장값을 비웠습니다.`);
} catch (error) {
const nextMessage = error instanceof Error ? error.message : `${entry.label} 저장값을 비우지 못했습니다.`;
setStorageFeedback(nextMessage);
message.error(nextMessage);
}
}, [message]);
if (isLoading) {
return (
<div className="shared-app-settings-page shared-app-settings-page--loading">
@@ -96,22 +590,22 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
return (
<div className="shared-app-settings-page">
<Flex align="center" justify="space-between" gap={12} wrap>
<div className="shared-app-settings-page__header">
<div>
<Title level={4}> </Title>
<Title level={4}></Title>
<Paragraph type="secondary">
.
, , .
</Paragraph>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void loadConfig()} disabled={isSaving}>
<div className="shared-app-settings-page__header-actions">
<Button icon={<ReloadOutlined />} onClick={() => void loadConfig()} disabled={isSaving || webPushLoading}>
</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={() => void form.submit()} loading={isSaving}>
</Button>
</Space>
</Flex>
</div>
</div>
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
@@ -121,128 +615,194 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
initialValues={savedConfig}
onFinish={(values) => void handleSave(values)}
>
<div className="shared-app-settings-page__grid">
<Card size="small" title="채팅 문맥 설정">
<Form.Item label="최근 메시지 수" name={['chat', 'maxContextMessages']}>
<InputNumber min={1} max={50} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="최대 문자 수" name={['chat', 'maxContextChars']}>
<InputNumber min={500} max={20000} step={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="Codex Live 최대 실행 시간(초)" name={['chat', 'codexLiveMaxExecutionSeconds']}>
<InputNumber min={60} max={7200} step={30} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="무출력 실패 시간(초)" name={['chat', 'codexLiveIdleTimeoutSeconds']}>
<InputNumber min={30} max={3600} step={10} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="재기동 완료 자동 실행 대기(초)" name={['chat', 'restartReservationCompletionDelaySeconds']}>
<InputNumber min={1} max={300} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['chat', 'receiveRoomNotifications']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
</Card>
<Tabs
activeKey={activeTabKey}
onChange={setActiveTabKey}
className="shared-app-settings-page__tabs"
items={[
{
key: 'general',
label: '기본',
children: (
<div className="shared-app-settings-page__panel">
<div className="shared-app-settings-page__section">
<div className="shared-app-settings-page__section-head">
<Text strong> </Text>
<Text type="secondary"> Codex Live .</Text>
</div>
<div className="shared-app-settings-page__field-grid">
<Form.Item label="최근 메시지 수" name={['chat', 'maxContextMessages']}>
<InputNumber min={1} max={50} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="최대 문자 수" name={['chat', 'maxContextChars']}>
<InputNumber min={500} max={20000} step={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="최대 실행 시간(초)" name={['chat', 'codexLiveMaxExecutionSeconds']}>
<InputNumber min={60} max={7200} step={30} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="무출력 실패 시간(초)" name={['chat', 'codexLiveIdleTimeoutSeconds']}>
<InputNumber min={30} max={3600} step={10} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="재기동 완료 자동 실행 대기(초)" name={['chat', 'restartReservationCompletionDelaySeconds']}>
<InputNumber min={1} max={300} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="통합 검색 단축키" name={['gestureShortcuts', 'openSearch']}>
<Input />
</Form.Item>
</div>
</div>
<Card size="small" title="자동접수 / 주기">
<Form.Item name={['automation', 'autoRefreshEnabled']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item label="자동 새로고침 간격(초)" name={['automation', 'autoRefreshIntervalSeconds']}>
<InputNumber min={1} max={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="자동접수 방식" name={['automation', 'autoReceiveScheduleType']}>
<Select
options={[
{ value: 'interval', label: '간격' },
{ value: 'daily', label: '매일' },
{ value: 'weekly', label: '매주' },
]}
/>
</Form.Item>
<Form.Item label="간격(초)" name={['automation', 'autoReceiveIntervalSeconds']}>
<InputNumber min={1} max={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="매일 시각" name={['automation', 'autoReceiveDailyTime']}>
<Input placeholder="09:00" />
</Form.Item>
<Form.Item label="매주 요일" name={['automation', 'autoReceiveWeeklyDay']}>
<Select options={getWeeklyScheduleOptions()} />
</Form.Item>
<Form.Item label="매주 시각" name={['automation', 'autoReceiveWeeklyTime']}>
<Input placeholder="09:00" />
</Form.Item>
</Card>
<div className="shared-app-settings-page__section">
<div className="shared-app-settings-page__section-head">
<Text strong> </Text>
<Text type="secondary"> PWA, , .</Text>
</div>
<div className="shared-app-settings-page__check-grid">
<Form.Item name={['chat', 'receiveRoomNotifications']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item name={['chat', 'guidePwaInstallForNotifications']} valuePropName="checked">
<Checkbox>PWA </Checkbox>
</Form.Item>
<Form.Item name={['chat', 'guideWebPushPermission']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item name={['chat', 'guideWebPushRegistration']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
</div>
</div>
</div>
),
},
{
key: 'web-push',
label: '웹 푸시',
children: (
<div className="shared-app-settings-page__panel">
<div className="shared-app-settings-page__section">
<div className="shared-app-settings-page__section-head">
<Text strong> </Text>
<Text type="secondary">, PWA , , .</Text>
</div>
<div className="shared-app-settings-page__status-grid">
<div className="shared-app-settings-page__status-item">
<span></span>
<strong>{getClientNotificationPermissionLabel(clientPermission)}</strong>
</div>
<div className="shared-app-settings-page__status-item">
<span>PWA</span>
<strong>{isStandaloneDisplayMode() ? '실행 중' : '브라우저 탭'}</strong>
</div>
<div className="shared-app-settings-page__status-item">
<span> </span>
<strong>{hasSecureOrigin() ? 'HTTPS 준비' : '등록 불가'}</strong>
</div>
<div className="shared-app-settings-page__status-item">
<span> </span>
<strong>{webPushConfigured ? '활성' : '비활성'}</strong>
</div>
<div className="shared-app-settings-page__status-item">
<span> </span>
<strong>{webPushRegistered ? '완료' : '미등록'}</strong>
</div>
<div className="shared-app-settings-page__status-item">
<span> ID</span>
<strong>{getSavedNotificationDeviceId() || '-'}</strong>
</div>
</div>
<div className="shared-app-settings-page__inline-actions">
<Button onClick={() => void syncWebPushStatus()} disabled={webPushLoading}>
</Button>
<Button onClick={() => void handleRequestNotificationPermission()} disabled={webPushLoading}>
</Button>
<Button type="primary" onClick={() => void handleRegisterWebPush()} loading={webPushLoading}>
</Button>
<Button danger onClick={() => void handleUnregisterWebPush()} disabled={webPushLoading || !webPushRegistered}>
</Button>
</div>
</div>
<Card size="small" title="자동화 기본값">
<Form.Item name={['planDefaults', 'jangsingProcessingRequired']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item name={['planDefaults', 'autoDeployToMain']} valuePropName="checked">
<Checkbox>main </Checkbox>
</Form.Item>
<Form.Item name={['planDefaults', 'openEditorAfterCreate']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
</Card>
{isAppleMobileDevice() && !isStandaloneDisplayMode() ? (
<Alert
showIcon
type="info"
message="아이폰/아이패드는 홈 화면에 추가한 PWA에서만 웹 푸시를 등록할 수 있습니다."
/>
) : null}
</div>
),
},
{
key: 'storage',
label: '저장값',
children: (
<div className="shared-app-settings-page__panel">
<Tabs
activeKey={selectedStorageGroup?.key}
onChange={setStorageGroupKey}
className="shared-app-settings-page__subtabs"
items={STORAGE_GROUPS.map((group) => ({
key: group.key,
label: group.label,
children: (
<div className="shared-app-settings-page__section">
<div className="shared-app-settings-page__section-head">
<Text strong>{group.label}</Text>
<Text type="secondary">{group.description}</Text>
</div>
{storageFeedback ? <Alert showIcon type="info" message={storageFeedback} /> : null}
<div className="shared-app-settings-page__storage-list">
{group.entries.map((entry) => {
const storageItemKey = buildStorageItemKey(entry);
<Card size="small" title="업무일지 자동화">
<Form.Item name={['worklogAutomation', 'autoCreateDailyWorklog']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item label="생성 시각" name={['worklogAutomation', 'dailyCreateTime']}>
<Input placeholder="18:00" />
</Form.Item>
<Form.Item name={['worklogAutomation', 'includeScreenshots']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item name={['worklogAutomation', 'includeChangedFiles']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item name={['worklogAutomation', 'includeCommandLogs']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item label="템플릿" name={['worklogAutomation', 'template']}>
<Select
options={[
{ value: 'simple', label: '간단' },
{ value: 'detailed', label: '상세' },
]}
/>
</Form.Item>
</Card>
<Card size="small" title="비용 표시 / 단축키">
<Form.Item label="백만 토큰당 기본 비용" name={['planCost', 'baseCostPerMillionTokens']}>
<InputNumber min={100} max={1000000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="재시도 비용 배수(%)" name={['planCost', 'retryCostMultiplierPercent']}>
<InputNumber min={0} max={500} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="시간 비용 배수(%)" name={['planCost', 'hourlyCostMultiplierPercent']}>
<InputNumber min={0} max={500} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="시간 단위" name={['planCost', 'timeCostUnit']}>
<Select options={PLAN_COST_TIME_UNIT_OPTIONS} />
</Form.Item>
<Form.Item label="주의 배수" name={['planCost', 'attentionCostThresholdMultiplier']}>
<InputNumber min={0.1} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="경고 배수" name={['planCost', 'warningCostThresholdMultiplier']}>
<InputNumber min={0.1} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="고비용 배수" name={['planCost', 'highCostThresholdMultiplier']}>
<InputNumber min={0.1} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="검색 단축키" name={['gestureShortcuts', 'openSearch']}>
<Input />
</Form.Item>
</Card>
</div>
return (
<div key={storageItemKey} className="shared-app-settings-page__storage-item">
<div className="shared-app-settings-page__storage-head">
<div>
<Text strong>{entry.label}</Text>
<Text type="secondary">{entry.description}</Text>
</div>
<Text type="secondary">{entry.scope === 'local' ? 'localStorage' : 'sessionStorage'}</Text>
</div>
<Text code>{entry.storageKey}</Text>
<Input.TextArea
rows={4}
value={storageDrafts[storageItemKey] ?? ''}
placeholder="값이 없으면 비워 둡니다."
onChange={(event) => {
handleStorageDraftChange(entry, event.target.value);
}}
/>
<div className="shared-app-settings-page__inline-actions">
<Button size="small" onClick={() => handleReloadStorageEntry(entry)}>
</Button>
<Button size="small" type="primary" onClick={() => handleSaveStorageEntry(entry)}>
</Button>
<Button size="small" danger onClick={() => handleClearStorageEntry(entry)}>
</Button>
</div>
</div>
);
})}
</div>
</div>
),
}))}
/>
</div>
),
},
]}
/>
</Form>
<Text type="secondary">
.
</Text>
</div>
);
}

View File

@@ -1891,6 +1891,78 @@ export async function fetchChatConversationDetail(
};
}
export async function fetchChatShareConversationDetail(
token: string,
options: {
sessionId: string;
limit?: number;
beforeMessageId?: number | null;
sharePin?: string | null;
},
) {
const query = new URLSearchParams();
if (options.limit != null) {
query.set('limit', String(options.limit));
}
if (Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0) {
query.set('beforeMessageId', String(options.beforeMessageId));
}
const response = await requestChatApi<ChatConversationDetailResponse>(
`/shares/${encodeURIComponent(token)}/conversations/${encodeURIComponent(options.sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
undefined,
{
allowUnauthenticated: true,
sharePin: options.sharePin,
timeoutMs: 20000,
},
);
const normalizedMessages = Array.isArray(response.messages)
? response.messages.map((message, index) => normalizeChatMessage(message, index))
: [];
const normalizedRequests = enrichFailedRequestsWithActivityLogs(
response.requests.map((item) => normalizeChatConversationRequest(item)),
response.activityLogs,
);
const visibleRequestIds = new Set(
normalizedMessages
.map((message) => message.clientRequestId?.trim() ?? '')
.filter(Boolean),
);
const hydratedMessages = hydrateActivityLogMessages(
replaceGenericFailureMessages(normalizedMessages, normalizedRequests, response.activityLogs),
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
).filter(
(message) =>
message.author !== 'system' ||
isActivityLogMessage(message) ||
isMissingRequestMessage(message) ||
isExecutionFailureMessage(message),
);
const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs);
return {
...response,
messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
item: {
...response.item,
lastMessagePreview: resolveConversationFailurePreview(
response.item.lastMessagePreview,
normalizedRequests,
response.activityLogs,
),
notifyOffline: resolveSyncedChatOfflineNotificationSetting(
response.item.sessionId,
response.item.notifyOffline,
getOrCreateClientId(),
),
},
requests: normalizedRequests,
};
}
export async function fetchChatRuntimeSnapshot() {
const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>('/runtime');
return response.item;
@@ -2576,6 +2648,8 @@ export type ChatShareSnapshot = {
processingCount: number;
unansweredCount: number;
};
oldestLoadedMessageId?: number | null;
hasOlderMessages?: boolean;
promptTarget?: {
sourceMessageId: number;
promptIndex: number;
@@ -2799,6 +2873,8 @@ export async function fetchChatShareSnapshot(
rooms?: ChatShareRoomSummary[];
activeSessionId?: string | null;
roomRequestCounts?: ChatShareSnapshot['roomRequestCounts'];
oldestLoadedMessageId?: number | null;
hasOlderMessages?: boolean;
promptTarget?: ChatShareSnapshot['promptTarget'];
refreshedAt: string;
}>(
@@ -2906,6 +2982,11 @@ export async function fetchChatShareSnapshot(
unansweredCount: Number.isFinite(response.roomRequestCounts.unansweredCount) ? response.roomRequestCounts.unansweredCount : 0,
}
: undefined,
oldestLoadedMessageId:
Number.isFinite(response.oldestLoadedMessageId) && Number(response.oldestLoadedMessageId) > 0
? Number(response.oldestLoadedMessageId)
: null,
hasOlderMessages: response.hasOlderMessages === true,
promptTarget: response.promptTarget ?? null,
refreshedAt: response.refreshedAt,
} satisfies ChatShareSnapshot;

View File

@@ -33,8 +33,10 @@
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: none;
overscroll-behavior-y: contain;
scrollbar-gutter: stable;
touch-action: pan-y;
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))
@@ -912,6 +914,18 @@
flex: 0 0 auto;
}
.chat-share-page__initial-load-alert.ant-alert {
margin-top: 10px;
border-radius: 14px;
border: 1px solid rgba(125, 211, 252, 0.45);
background: linear-gradient(180deg, rgba(240, 249, 255, 0.96) 0%, rgba(224, 242, 254, 0.94) 100%);
}
.chat-share-page__initial-load-alert .ant-alert-description {
font-size: 12px;
line-height: 1.5;
}
.chat-share-page__live-dot {
width: 10px;
height: 10px;
@@ -1412,6 +1426,48 @@
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
}
.chat-share-page__app-settings-drawer-shell .ant-drawer-content,
.chat-share-page__app-settings-drawer-shell .ant-drawer-wrapper-body {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background:
linear-gradient(180deg, #f7fafc 0%, #eef3f9 100%),
radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 26%);
}
.chat-share-page__app-settings-drawer-shell .ant-drawer-header,
.chat-share-page__app-settings-drawer-shell .ant-drawer-header-title,
.chat-share-page__app-settings-drawer-shell .ant-drawer-extra {
min-width: 0;
}
.chat-share-page__app-settings-drawer-shell .ant-drawer-header {
border-bottom: 1px solid rgba(196, 210, 226, 0.96);
padding: 14px 18px;
background: rgba(248, 250, 252, 0.94);
}
.chat-share-page__app-settings-drawer-shell .ant-drawer-body {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
}
.chat-share-page__app-settings-drawer-body,
.chat-share-page__app-settings-drawer-body .shared-app-settings-page {
display: flex;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.chat-share-page__room-settings-actions {
display: flex;
align-items: center;
@@ -1541,9 +1597,10 @@
.chat-share-page__process-inspector--fullscreen {
inset: 0;
width: 100vw;
max-height: 100dvh;
height: 100dvh;
width: auto;
height: auto;
max-width: none;
max-height: none;
border-radius: 0;
border-width: 0;
}
@@ -1672,6 +1729,49 @@
overflow: auto;
}
.chat-share-page__process-inspector-sections--fullscreen {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
align-items: stretch;
grid-auto-flow: dense;
padding:
14px
max(18px, env(safe-area-inset-right, 0px))
18px
max(18px, env(safe-area-inset-left, 0px));
}
.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--log-collapsed,
.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--checklist-collapsed.chat-share-page__process-inspector-sections--narratives-collapsed {
grid-template-columns: minmax(0, 1fr);
}
.chat-share-page__process-inspector-sections--fullscreen .chat-share-page__process-inspector-section--checklist,
.chat-share-page__process-inspector-sections--fullscreen .chat-share-page__process-inspector-section--narratives,
.chat-share-page__process-inspector-sections--fullscreen .chat-share-page__process-inspector-section--log {
grid-column: auto;
grid-row: auto;
}
.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--log-collapsed
.chat-share-page__process-inspector-section--checklist,
.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--log-collapsed
.chat-share-page__process-inspector-section--narratives,
.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--log-collapsed
.chat-share-page__process-inspector-section--log,
.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--checklist-collapsed.chat-share-page__process-inspector-sections--narratives-collapsed
.chat-share-page__process-inspector-section--log {
grid-column: 1;
grid-row: auto;
}
.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--checklist-collapsed:not(.chat-share-page__process-inspector-sections--narratives-collapsed)
.chat-share-page__process-inspector-section--narratives,
.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--narratives-collapsed:not(.chat-share-page__process-inspector-sections--checklist-collapsed)
.chat-share-page__process-inspector-section--checklist {
grid-column: 1 / -1;
grid-row: auto;
}
.chat-share-page__process-inspector-section {
display: grid;
gap: 8px;
@@ -2385,6 +2485,10 @@
padding-top: calc(14px + env(safe-area-inset-top, 0px));
}
.chat-share-page__app-settings-drawer-shell .ant-drawer-header {
padding-top: calc(14px + env(safe-area-inset-top, 0px));
}
.chat-share-page__program-app-shell--system-chat-room {
padding: 0;
}

View File

@@ -1,7 +1,7 @@
import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons';
import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useParams } from 'react-router-dom';
import { FullscreenPreviewModal } from '../../../components/previewer';
@@ -43,6 +43,7 @@ import {
completeChatShareManualBadge,
createChatShareRoom,
fetchChatConversationDetail,
fetchChatShareConversationDetail,
fetchChatConversations,
deleteChatShareRoom,
fetchChatShareRuntimeSnapshot,
@@ -82,6 +83,7 @@ import type {
import { isPromptResolved } from '../mainChatPanel/promptState';
import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { isTypingTarget } from '../mainView/utils';
import { applyViewportCssVars, scheduleViewportRecoverySync } from '../viewportCssVars';
import { isPreviewRuntime } from '../previewRuntime';
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../pwa/installManifest';
@@ -109,6 +111,12 @@ const SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY = 'codex-live-share-room-sna
const SHARE_ROOM_SNAPSHOT_SESSION_STORAGE_KEY_PREFIX = 'codex-live-share-room-snapshot:v1';
const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6;
const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000;
const SHARE_EDGE_NAVIGATION_HOTZONE_PX = 28;
const SHARE_APPS_EDGE_MIDDLE_BAND_RATIO = 0.2;
const SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX = 16;
const SHARE_EDGE_GESTURE_OPEN_APPS_PX = 96;
const SHARE_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX = 64;
const SHARE_HISTORY_PAGE_SIZE = 40;
const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [
{ value: 'always', label: '매번 묻기', minutes: 0 },
{ value: '5', label: '5분 유지', minutes: 5 },
@@ -177,6 +185,18 @@ type ShareSearchResult = {
type ShareSearchIndexedResult = ShareSearchResult & {
searchText: string;
};
type ShareEdgeGestureTracking =
| {
startX: number;
startY: number;
direction: 'back';
}
| {
startX: number;
startY: number;
direction: 'apps';
opened: boolean;
};
type ShareSearchPanelMode = 'all' | 'apps';
type ShareWorkServerVersionStatus = 'latest' | 'unknown' | 'update-available' | 'build-required';
type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied';
@@ -1883,6 +1903,31 @@ function isMobileShareInputTarget(target: EventTarget | null): target is HTMLEle
return Boolean(inputTarget) && isSnapshotDeferrableFocusTarget(inputTarget);
}
function isMobileTouchViewport() {
if (typeof window === 'undefined') {
return false;
}
return window.innerWidth <= 768
&& (window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches);
}
function isShareEdgeGestureIgnoredTarget(target: EventTarget | null) {
if (isTypingTarget(target)) {
return true;
}
if (!(target instanceof HTMLElement)) {
return false;
}
return Boolean(
target.closest(
'button, a, summary, [role="button"], [data-share-edge-gesture-ignore], .ant-modal-root, .ant-drawer, .ant-dropdown, .ant-select-dropdown, .chat-share-page__program-shell, .chat-share-page__program-minimized',
),
);
}
function getDefaultProgramMinimizedPosition() {
if (typeof window === 'undefined') {
return { x: PROGRAM_MINIMIZED_VIEWPORT_PADDING, y: PROGRAM_MINIMIZED_VIEWPORT_PADDING };
@@ -2071,6 +2116,38 @@ function replaceChatShareSnapshotRequest(snapshot: ChatShareSnapshot, nextReques
};
}
function mergeChatShareSnapshotHistory(
snapshot: ChatShareSnapshot,
detailPage: {
requests: ChatConversationRequest[];
messages: ChatMessage[];
activityLogs: ChatShareSnapshot['activityLogs'];
oldestLoadedMessageId: number | null;
hasOlderMessages: boolean;
},
): ChatShareSnapshot {
const mergedRequests = [...detailPage.requests, ...snapshot.requests];
const mergedMessages = [...detailPage.messages, ...snapshot.messages];
const mergedActivityLogs = [...detailPage.activityLogs, ...snapshot.activityLogs];
return {
...snapshot,
detailLevel: detailPage.hasOlderMessages ? 'initial' : 'full',
requests: mergedRequests.filter(
(request, index, array) => array.findIndex((item) => item.requestId === request.requestId) === index,
),
messages: mergedMessages.filter(
(message, index, array) => array.findIndex((item) => item.id === message.id) === index,
),
activityLogs: mergedActivityLogs.filter(
(activityLog, index, array) => array.findIndex((item) => item.requestId === activityLog.requestId) === index,
),
oldestLoadedMessageId: detailPage.oldestLoadedMessageId,
hasOlderMessages: detailPage.hasOlderMessages,
refreshedAt: new Date().toISOString(),
};
}
function buildShareVisibleText(text: string) {
return stripHiddenPreviewTags(extractAttachmentPreviewUrls(text).strippedText).trim();
}
@@ -4014,8 +4091,11 @@ export function ChatSharePage() {
const [snapshot, setSnapshot] = useState<ChatShareSnapshot | null>(initialCachedSnapshot);
const [requestedRoomSessionId, setRequestedRoomSessionId] = useState<string>(initialRequestedRoomSessionId);
const requestedRoomSessionIdRef = useRef(requestedRoomSessionId);
const skipNextRequestedRoomRefreshRef = useRef(false);
const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null);
const [, setIsRefreshing] = useState(false);
const [isLoadingFullSnapshot, setIsLoadingFullSnapshot] = useState(false);
const [isLoadingOlderShareHistory, setIsLoadingOlderShareHistory] = useState(false);
const [isLiveConnected, setIsLiveConnected] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [requiresAccessPin, setRequiresAccessPin] = useState(false);
@@ -4848,12 +4928,6 @@ export function ChatSharePage() {
setRequiresAccessPin(false);
setAccessPinSubmitError('');
if (initialLoad && nextSnapshot.detailLevel === 'initial') {
window.setTimeout(() => {
void refreshSnapshot({ silent: true });
}, 0);
}
return true;
} catch (error) {
if (error instanceof ChatApiError && error.status === 401 && (error.code === 'share_pin_required' || error.code === 'share_pin_invalid')) {
@@ -4892,6 +4966,42 @@ export function ChatSharePage() {
snapshotRefreshPromiseRef.current = refreshTask;
return refreshTask;
}, [normalizedToken]);
const handleLoadOlderShareHistory = useCallback(async () => {
if (!normalizedToken || !snapshot?.conversation.sessionId || !snapshot.hasOlderMessages || !snapshot.oldestLoadedMessageId) {
return;
}
setIsLoadingFullSnapshot(true);
setIsLoadingOlderShareHistory(true);
try {
const scrollContainer = scrollContainerRef.current;
const previousScrollHeight = scrollContainer?.scrollHeight ?? 0;
const previousScrollTop = scrollContainer?.scrollTop ?? 0;
const detailPage = await fetchChatShareConversationDetail(normalizedToken, {
sessionId: snapshot.conversation.sessionId,
beforeMessageId: snapshot.oldestLoadedMessageId,
limit: SHARE_HISTORY_PAGE_SIZE,
sharePin: getStoredChatShareAccessPin(normalizedToken) || undefined,
});
setSnapshot((current) => (current ? mergeChatShareSnapshotHistory(current, detailPage) : current));
window.requestAnimationFrame(() => {
if (!scrollContainer) {
return;
}
const nextScrollHeight = scrollContainer.scrollHeight;
scrollContainer.scrollTop = Math.max(0, previousScrollTop + (nextScrollHeight - previousScrollHeight));
});
} catch (error) {
message.error(error instanceof Error ? error.message : '이전 대화를 불러오지 못했습니다.');
} finally {
setIsLoadingFullSnapshot(false);
setIsLoadingOlderShareHistory(false);
}
}, [message, normalizedToken, snapshot]);
const handleDeleteShareRoom = useCallback(async (room: ChatShareRoomSummary) => {
if (!normalizedToken || !room.sessionId || isDeletingRoom) {
return;
@@ -5619,6 +5729,39 @@ export function ChatSharePage() {
captureTarget.setPointerCapture(event.pointerId);
}, []);
const clearProgramMinimizedDragState = useCallback(() => {
const dragState = programMinimizedDragStateRef.current;
if (!dragState) {
programMinimizedMovedRef.current = false;
return;
}
if (dragState.captureTarget.hasPointerCapture(dragState.pointerId)) {
dragState.captureTarget.releasePointerCapture(dragState.pointerId);
}
programMinimizedDragStateRef.current = null;
programMinimizedMovedRef.current = false;
}, []);
const handleProgramMinimizedActionPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
clearProgramMinimizedDragState();
}, [clearProgramMinimizedDragState]);
const runProgramMinimizedActionAfterPointerCycle = useCallback((action: () => void) => {
if (typeof window === 'undefined') {
action();
return;
}
window.requestAnimationFrame(() => {
action();
});
}, []);
const handleRestoreProgram = useCallback((targetKey: string) => {
let restoredTarget: ShareProgramTarget | null = null;
@@ -5693,8 +5836,13 @@ export function ChatSharePage() {
size="small"
icon={<AppstoreOutlined />}
className="chat-share-page__program-minimized-button"
onClick={() => {
handleRestoreProgram(item.target.key);
onPointerDown={handleProgramMinimizedActionPointerDown}
onClick={(event: ReactMouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
runProgramMinimizedActionAfterPointerCycle(() => {
handleRestoreProgram(item.target.key);
});
}}
>
@@ -5705,8 +5853,13 @@ export function ChatSharePage() {
className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close"
icon={<CloseOutlined />}
aria-label="프로그램 닫기"
onClick={() => {
handleCloseMinimizedProgram(item.target.key);
onPointerDown={handleProgramMinimizedActionPointerDown}
onClick={(event: ReactMouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
runProgramMinimizedActionAfterPointerCycle(() => {
handleCloseMinimizedProgram(item.target.key);
});
}}
/>
</div>
@@ -5979,6 +6132,11 @@ export function ChatSharePage() {
return;
}
if (skipNextRequestedRoomRefreshRef.current) {
skipNextRequestedRoomRefreshRef.current = false;
return;
}
void refreshSnapshot({ silent: true });
}, [normalizedToken, refreshSnapshot, requestedRoomSessionId]);
useEffect(() => {
@@ -6001,10 +6159,11 @@ export function ChatSharePage() {
}
requestedRoomSessionIdRef.current = stabilizedRoomSessionId;
skipNextRequestedRoomRefreshRef.current = snapshot?.detailLevel === 'initial';
writeStoredShareLastRoomSessionId(normalizedToken, stabilizedRoomSessionId);
writeShareRoomSessionIdToLocation(stabilizedRoomSessionId, 'replace');
setRequestedRoomSessionId(stabilizedRoomSessionId);
}, [activeShareRoomSessionId, normalizedToken, shareRooms]);
}, [activeShareRoomSessionId, normalizedToken, shareRooms, snapshot?.detailLevel]);
useEffect(() => {
if (!requestedRoomSessionId) {
@@ -6534,7 +6693,7 @@ export function ChatSharePage() {
if (cachedSnapshot) {
setSnapshot(cachedSnapshot);
}
setIsRoomSwitching(true);
setIsRoomSwitching(!cachedSnapshot);
requestedRoomSessionIdRef.current = normalizedSessionId;
writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId);
writeShareRoomSessionIdToLocation(normalizedSessionId, 'push');
@@ -7740,7 +7899,15 @@ export function ChatSharePage() {
</div>
)}
</div>
<div className="chat-share-page__process-inspector-sections">
<div
className={[
'chat-share-page__process-inspector-sections',
processInspectorMode === 'fullscreen' ? 'chat-share-page__process-inspector-sections--fullscreen' : '',
isProcessInspectorChecklistCollapsed ? 'chat-share-page__process-inspector-sections--checklist-collapsed' : '',
isProcessInspectorNarrativesCollapsed ? 'chat-share-page__process-inspector-sections--narratives-collapsed' : '',
isProcessInspectorLogCollapsed ? 'chat-share-page__process-inspector-sections--log-collapsed' : '',
].filter(Boolean).join(' ')}
>
<section className="chat-share-page__process-inspector-section chat-share-page__process-inspector-section--checklist">
<div className="chat-share-page__process-inspector-section-head">
<Text strong> </Text>
@@ -7916,8 +8083,10 @@ export function ChatSharePage() {
try {
const roomSnapshot = await fetchChatShareSnapshot(normalizedToken, {
sessionId: normalizedSessionId,
view: 'initial',
sharePin,
});
writeStoredShareRoomSnapshot(normalizedToken, roomSnapshot);
setShareRoomPendingCountsBySessionId((current) => {
const nextCounts = resolveShareRoomPendingCounts(roomSnapshot);
@@ -8394,6 +8563,7 @@ export function ChatSharePage() {
? renderEmbeddedSharePlayApp(programTarget.appId, closeProgramTarget, normalizedToken)
: null;
const isServerCommandDrawerOpen = programTarget?.appId === 'server-command';
const isAppSettingsDrawerOpen = programTarget?.appId === 'app-settings';
const indexedContentSearchResults = useMemo<ShareSearchIndexedResult[]>(() => {
const results: ShareSearchIndexedResult[] = [];
@@ -8832,19 +9002,19 @@ export function ChatSharePage() {
},
]
: []),
...(canCreateSharedRooms
...(shareAllowedAppIdSet.has('app-settings')
? [
{
key: 'conversation-room-create',
key: 'conversation-personal-settings',
label: (
<span className="chat-share-page__settings-item">
<span className="chat-share-page__settings-item-title"> </span>
<span className="chat-share-page__settings-item-title"></span>
<span className="chat-share-page__settings-item-description">
.
, , .
</span>
</span>
),
icon: <PlusOutlined />,
icon: <SettingOutlined />,
},
]
: []),
@@ -8898,11 +9068,12 @@ export function ChatSharePage() {
allowedPlayAppEntries.length,
canSendMessage,
canOpenSharedRoomSettings,
canCreateSharedRooms,
hasWorkServerCommandApp,
isClearingConversation,
normalizedToken,
selectedTokenUsageSetting,
shareAllowedAppIdSet,
sharePermissionSet,
shareWorkServerCommand,
snapshot?.conversation.title,
selectedShareRoomSessionId,
@@ -8911,6 +9082,12 @@ export function ChatSharePage() {
tokenUsageOverview.fiveHourCountdownLabel,
],
);
const openShareAppsPanel = useCallback(() => {
setSearchPanelMode('apps');
setSearchKeyword('');
setIsShareRoomListVisible(false);
setIsSearchOpen(true);
}, []);
const handleShareHeaderSettingsClick = useCallback<NonNullable<MenuProps['onClick']>>(
({ key }) => {
if (key === 'conversation-search') {
@@ -8926,9 +9103,7 @@ export function ChatSharePage() {
}
if (key === 'conversation-apps') {
setSearchPanelMode('apps');
setSearchKeyword('');
setIsSearchOpen(true);
openShareAppsPanel();
return;
}
@@ -8942,8 +9117,8 @@ export function ChatSharePage() {
return;
}
if (key === 'conversation-room-create') {
openCreateRoomDialog();
if (key === 'conversation-personal-settings') {
openProgramTarget(buildShareManagementProgramTarget('app-settings', '개인설정'));
return;
}
@@ -8956,8 +9131,127 @@ export function ChatSharePage() {
void handleClearConversation();
}
},
[handleClearConversation, handleReloadPage, openCreateRoomDialog, openProgramTarget, openSharedRoomSettings],
[handleClearConversation, handleReloadPage, openProgramTarget, openShareAppsPanel, openSharedRoomSettings],
);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer || !isMobileTouchViewport()) {
return undefined;
}
const isBlockedByOverlay =
isSearchOpen
|| isTokenUsageOpen
|| isRoomSettingsOpen
|| isCreateRoomOpen
|| isOriginReplyModalOpen
|| Boolean(sourceGroupDetail)
|| Boolean(programTarget)
|| Boolean(activeProcessInspectorRequestId);
let tracking: ShareEdgeGestureTracking | null = null;
const resetTracking = () => {
tracking = null;
};
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch || event.touches.length !== 1 || isBlockedByOverlay || isShareEdgeGestureIgnoredTarget(event.target)) {
tracking = null;
return;
}
const centerBandStart = window.innerHeight * SHARE_APPS_EDGE_MIDDLE_BAND_RATIO;
const centerBandEnd = window.innerHeight * (1 - SHARE_APPS_EDGE_MIDDLE_BAND_RATIO);
if (
touch.clientX >= window.innerWidth - SHARE_EDGE_NAVIGATION_HOTZONE_PX
&& touch.clientY >= centerBandStart
&& touch.clientY <= centerBandEnd
) {
tracking = {
startX: touch.clientX,
startY: touch.clientY,
direction: 'apps',
opened: false,
};
return;
}
if (touch.clientX <= SHARE_EDGE_NAVIGATION_HOTZONE_PX) {
tracking = {
startX: touch.clientX,
startY: touch.clientY,
direction: 'back',
};
return;
}
tracking = null;
};
const handleTouchMove = (event: TouchEvent) => {
const touch = event.touches[0];
if (!tracking || !touch) {
return;
}
const deltaX = touch.clientX - tracking.startX;
const deltaY = touch.clientY - tracking.startY;
if (Math.abs(deltaY) > SHARE_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX) {
tracking = null;
return;
}
if (tracking.direction === 'back') {
if (deltaX >= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX) {
event.preventDefault();
}
return;
}
if (deltaX <= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX * -1) {
event.preventDefault();
}
if (!tracking.opened && deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) {
tracking.opened = true;
openShareAppsPanel();
}
};
scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: true, capture: true });
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
scrollContainer.addEventListener('touchend', resetTracking, { passive: true, capture: true });
scrollContainer.addEventListener('touchcancel', resetTracking, { passive: true, capture: true });
return () => {
scrollContainer.removeEventListener('touchstart', handleTouchStart, true);
scrollContainer.removeEventListener('touchmove', handleTouchMove, true);
scrollContainer.removeEventListener('touchend', resetTracking, true);
scrollContainer.removeEventListener('touchcancel', resetTracking, true);
};
}, [
activeProcessInspectorRequestId,
isCreateRoomOpen,
isOriginReplyModalOpen,
isRoomSettingsOpen,
isSearchOpen,
isTokenUsageOpen,
openShareAppsPanel,
programTarget,
sourceGroupDetail,
]);
const shareExpandModeMenuItems = useMemo<MenuProps['items']>(
() => [
{
@@ -9363,6 +9657,28 @@ export function ChatSharePage() {
<Text type="secondary" className="chat-share-page__header-summary">
{headerSummaryLabel}
</Text>
{snapshot.hasOlderMessages ? (
<Alert
showIcon
type="info"
className="chat-share-page__initial-load-alert"
message="최근 대화만 먼저 불러왔습니다."
description="초기 진입 속도를 위해 최신 구간만 표시 중입니다. 필요할 때만 이전 대화를 페이지 단위로 더 불러오세요."
action={
<Button
size="small"
type="primary"
icon={<ThunderboltOutlined />}
loading={isLoadingFullSnapshot || isLoadingOlderShareHistory}
onClick={() => {
void handleLoadOlderShareHistory();
}}
>
</Button>
}
/>
) : null}
</div>
<div className="chat-share-page__section-actions">
{canToggleShareRoomList ? (
@@ -10724,8 +11040,36 @@ export function ChatSharePage() {
</div>
) : null}
</Drawer>
<Drawer
open={isAppSettingsDrawerOpen}
title="개인설정"
rootClassName="chat-share-page__app-settings-drawer-shell"
className="chat-share-page__app-settings-drawer"
placement={isServerCommandDrawerMobile ? 'bottom' : 'right'}
width={isServerCommandDrawerMobile ? undefined : '100vw'}
height={isServerCommandDrawerMobile ? '100dvh' : undefined}
maskClosable={false}
destroyOnHidden={false}
extra={(
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="개인설정 새로고침"
title="개인설정 새로고침"
icon={<ReloadOutlined />}
onClick={handleReloadProgram}
/>
)}
onClose={closeProgramTarget}
>
{isAppSettingsDrawerOpen ? (
<div className="chat-share-page__app-settings-drawer-body">
<SharedAppSettingsPage shareToken={normalizedToken} />
</div>
) : null}
</Drawer>
<FullscreenPreviewModal
open={Boolean(programTarget) && !isServerCommandDrawerOpen}
open={Boolean(programTarget) && !isServerCommandDrawerOpen && !isAppSettingsDrawerOpen}
title={programTarget?.label ?? '공유 프로그램'}
meta={programTarget?.meta ?? '공유 토큰 실행'}
actions={
@@ -10825,13 +11169,6 @@ export function ChatSharePage() {
}
/>
</div>
) : programTarget.appId === 'app-settings' ? (
<div
key={`${programTarget.key}:${programReloadKey}`}
className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface"
>
<SharedAppSettingsPage shareToken={normalizedToken} />
</div>
) : (
<div key={`${programTarget.key}:${programReloadKey}`} className="chat-share-page__program-app-shell">
<ChatPreviewBody