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); : await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds);
const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null; const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null;
const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages); const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages);
const oldestLoadedMessageId = detailPage?.oldestLoadedMessageId ?? null;
const hasOlderMessages = detailPage?.hasOlderMessages ?? false;
if (tokenPayload.kind === 'prompt' && !promptTarget) { if (tokenPayload.kind === 'prompt' && !promptTarget) {
return null; return null;
@@ -1293,6 +1295,8 @@ async function buildChatShareSnapshot(
messages: scopedMessages, messages: scopedMessages,
activityLogs, activityLogs,
roomRequestCounts, roomRequestCounts,
oldestLoadedMessageId,
hasOlderMessages,
promptTarget, promptTarget,
detailLevel, detailLevel,
} satisfies { } satisfies {
@@ -1307,6 +1311,8 @@ async function buildChatShareSnapshot(
processingCount: number; processingCount: number;
unansweredCount: number; unansweredCount: number;
} | undefined; } | undefined;
oldestLoadedMessageId: number | null;
hasOlderMessages: boolean;
promptTarget: promptTarget:
| { | {
sourceMessageId: number; sourceMessageId: number;
@@ -2580,6 +2586,8 @@ export async function registerChatRoutes(app: FastifyInstance) {
messages: shareSnapshot.messages, messages: shareSnapshot.messages,
activityLogs: shareSnapshot.activityLogs, activityLogs: shareSnapshot.activityLogs,
roomRequestCounts: shareSnapshot.roomRequestCounts, roomRequestCounts: shareSnapshot.roomRequestCounts,
oldestLoadedMessageId: shareSnapshot.oldestLoadedMessageId,
hasOlderMessages: shareSnapshot.hasOlderMessages,
rooms: resolvedRoomContext.rooms, rooms: resolvedRoomContext.rooms,
activeSessionId: activeRoom.sessionId, activeSessionId: activeRoom.sessionId,
promptTarget: shareSnapshot.promptTarget, 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) => { app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/clear`, async (request, reply) => {
const params = z.object({ const params = z.object({
token: z.string().trim().min(1).max(16000), token: z.string().trim().min(1).max(16000),

View File

@@ -1,10 +1,17 @@
.shared-app-settings-page { .shared-app-settings-page {
display: flex; display: flex;
flex: 1 1 auto;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
padding: 16px; width: 100%;
min-height: 100%; height: 100%;
background: #f7f8fb; 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 { .shared-app-settings-page--loading {
@@ -12,18 +19,128 @@
justify-content: center; justify-content: center;
} }
.shared-app-settings-page__grid { .shared-app-settings-page__header {
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 {
display: flex; 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; 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 { ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Checkbox, Flex, Form, Input, InputNumber, Select, Space, Spin, Typography } from 'antd'; import { Alert, App, Button, Checkbox, Form, Input, InputNumber, Spin, Tabs, Typography } from 'antd';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { fetchWebPushConfig } from './notificationApi';
import { NOTIFICATION_DEVICE_ID_STORAGE_KEY, getSavedNotificationDeviceId } from './notificationIdentity';
import { import {
DEFAULT_APP_CONFIG, DEFAULT_APP_CONFIG,
getWeeklyScheduleOptions, APP_CONFIG_STORAGE_KEY,
saveAppConfigToServer, saveAppConfigToServer,
type AppConfig, type AppConfig,
type PlanCostTimeUnit,
} from './appConfig'; } from './appConfig';
import {
TOKEN_ACCESS_STORAGE_KEY,
} from './tokenAccess';
import {
clearWebPushSubscriptionRegistration,
ensureWebPushSubscriptionRegistered,
syncExistingWebPushSubscriptionRegistration,
} from './webPushRegistration';
import { isPreviewRuntime } from './previewRuntime';
import './SharedAppSettingsPage.css'; import './SharedAppSettingsPage.css';
const { Paragraph, Text, Title } = Typography; const { Paragraph, Text, Title } = Typography;
const PLAN_COST_TIME_UNIT_OPTIONS: Array<{ value: PlanCostTimeUnit; label: string }> = [ const WEB_PUSH_METADATA_STORAGE_KEY = 'work-server.web-push.registration-meta.v1';
{ value: 'hour', label: '시간' }, const SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-share-immediate-send-pinned-by-token';
{ value: 'minute', label: '분' }, const SHARE_LAST_ROOM_STORAGE_KEY = 'codex-live-share-last-room-by-token';
{ value: 'second', label: '초' }, 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 = { type SharedAppSettingsPageProps = {
shareToken: string; shareToken: string;
}; };
type SharedAppSettingsFormValue = AppConfig; 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) { export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps) {
const { message } = App.useApp(); const { message } = App.useApp();
@@ -31,6 +326,64 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [savedConfig, setSavedConfig] = useState<AppConfig>(DEFAULT_APP_CONFIG); 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 () => { const loadConfig = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -53,12 +406,14 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
const nextConfig = payload.config ?? DEFAULT_APP_CONFIG; const nextConfig = payload.config ?? DEFAULT_APP_CONFIG;
setSavedConfig(nextConfig); setSavedConfig(nextConfig);
form.setFieldsValue(nextConfig); form.setFieldsValue(nextConfig);
await syncWebPushStatus();
loadStorageDrafts();
} catch (error) { } catch (error) {
setErrorMessage(error instanceof Error ? error.message : '앱 설정을 불러오지 못했습니다.'); setErrorMessage(error instanceof Error ? error.message : '앱 설정을 불러오지 못했습니다.');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [form, shareToken]); }, [form, loadStorageDrafts, shareToken, syncWebPushStatus]);
useEffect(() => { useEffect(() => {
void loadConfig(); void loadConfig();
@@ -76,7 +431,7 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
}); });
setSavedConfig(saved); setSavedConfig(saved);
form.setFieldsValue(saved); form.setFieldsValue(saved);
message.success('설정을 저장했습니다.'); message.success('개인설정을 저장했습니다.');
} catch (error) { } catch (error) {
setErrorMessage(error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.'); setErrorMessage(error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.');
} finally { } finally {
@@ -86,6 +441,145 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
[form, message, shareToken], [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) { if (isLoading) {
return ( return (
<div className="shared-app-settings-page shared-app-settings-page--loading"> <div className="shared-app-settings-page shared-app-settings-page--loading">
@@ -96,22 +590,22 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
return ( return (
<div className="shared-app-settings-page"> <div className="shared-app-settings-page">
<Flex align="center" justify="space-between" gap={12} wrap> <div className="shared-app-settings-page__header">
<div> <div>
<Title level={4}> </Title> <Title level={4}></Title>
<Paragraph type="secondary"> <Paragraph type="secondary">
. , , .
</Paragraph> </Paragraph>
</div> </div>
<Space> <div className="shared-app-settings-page__header-actions">
<Button icon={<ReloadOutlined />} onClick={() => void loadConfig()} disabled={isSaving}> <Button icon={<ReloadOutlined />} onClick={() => void loadConfig()} disabled={isSaving || webPushLoading}>
</Button> </Button>
<Button type="primary" icon={<SaveOutlined />} onClick={() => void form.submit()} loading={isSaving}> <Button type="primary" icon={<SaveOutlined />} onClick={() => void form.submit()} loading={isSaving}>
</Button> </Button>
</Space> </div>
</Flex> </div>
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null} {errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
@@ -121,128 +615,194 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
initialValues={savedConfig} initialValues={savedConfig}
onFinish={(values) => void handleSave(values)} onFinish={(values) => void handleSave(values)}
> >
<div className="shared-app-settings-page__grid"> <Tabs
<Card size="small" title="채팅 문맥 설정"> activeKey={activeTabKey}
<Form.Item label="최근 메시지 수" name={['chat', 'maxContextMessages']}> onChange={setActiveTabKey}
<InputNumber min={1} max={50} style={{ width: '100%' }} /> className="shared-app-settings-page__tabs"
</Form.Item> items={[
<Form.Item label="최대 문자 수" name={['chat', 'maxContextChars']}> {
<InputNumber min={500} max={20000} step={100} style={{ width: '100%' }} /> key: 'general',
</Form.Item> label: '기본',
<Form.Item label="Codex Live 최대 실행 시간(초)" name={['chat', 'codexLiveMaxExecutionSeconds']}> children: (
<InputNumber min={60} max={7200} step={30} style={{ width: '100%' }} /> <div className="shared-app-settings-page__panel">
</Form.Item> <div className="shared-app-settings-page__section">
<Form.Item label="무출력 실패 시간(초)" name={['chat', 'codexLiveIdleTimeoutSeconds']}> <div className="shared-app-settings-page__section-head">
<InputNumber min={30} max={3600} step={10} style={{ width: '100%' }} /> <Text strong> </Text>
</Form.Item> <Text type="secondary"> Codex Live .</Text>
<Form.Item label="재기동 완료 자동 실행 대기(초)" name={['chat', 'restartReservationCompletionDelaySeconds']}> </div>
<InputNumber min={1} max={300} style={{ width: '100%' }} /> <div className="shared-app-settings-page__field-grid">
</Form.Item> <Form.Item label="최근 메시지 수" name={['chat', 'maxContextMessages']}>
<Form.Item name={['chat', 'receiveRoomNotifications']} valuePropName="checked"> <InputNumber min={1} max={50} style={{ width: '100%' }} />
<Checkbox> </Checkbox> </Form.Item>
</Form.Item> <Form.Item label="최대 문자 수" name={['chat', 'maxContextChars']}>
</Card> <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="자동접수 / 주기"> <div className="shared-app-settings-page__section">
<Form.Item name={['automation', 'autoRefreshEnabled']} valuePropName="checked"> <div className="shared-app-settings-page__section-head">
<Checkbox> </Checkbox> <Text strong> </Text>
</Form.Item> <Text type="secondary"> PWA, , .</Text>
<Form.Item label="자동 새로고침 간격(초)" name={['automation', 'autoRefreshIntervalSeconds']}> </div>
<InputNumber min={1} max={3600} style={{ width: '100%' }} /> <div className="shared-app-settings-page__check-grid">
</Form.Item> <Form.Item name={['chat', 'receiveRoomNotifications']} valuePropName="checked">
<Form.Item label="자동접수 방식" name={['automation', 'autoReceiveScheduleType']}> <Checkbox> </Checkbox>
<Select </Form.Item>
options={[ <Form.Item name={['chat', 'guidePwaInstallForNotifications']} valuePropName="checked">
{ value: 'interval', label: '간격' }, <Checkbox>PWA </Checkbox>
{ value: 'daily', label: '매일' }, </Form.Item>
{ value: 'weekly', label: '매주' }, <Form.Item name={['chat', 'guideWebPushPermission']} valuePropName="checked">
]} <Checkbox> </Checkbox>
/> </Form.Item>
</Form.Item> <Form.Item name={['chat', 'guideWebPushRegistration']} valuePropName="checked">
<Form.Item label="간격(초)" name={['automation', 'autoReceiveIntervalSeconds']}> <Checkbox> </Checkbox>
<InputNumber min={1} max={3600} style={{ width: '100%' }} /> </Form.Item>
</Form.Item> </div>
<Form.Item label="매일 시각" name={['automation', 'autoReceiveDailyTime']}> </div>
<Input placeholder="09:00" /> </div>
</Form.Item> ),
<Form.Item label="매주 요일" name={['automation', 'autoReceiveWeeklyDay']}> },
<Select options={getWeeklyScheduleOptions()} /> {
</Form.Item> key: 'web-push',
<Form.Item label="매주 시각" name={['automation', 'autoReceiveWeeklyTime']}> label: '웹 푸시',
<Input placeholder="09:00" /> children: (
</Form.Item> <div className="shared-app-settings-page__panel">
</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__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="자동화 기본값"> {isAppleMobileDevice() && !isStandaloneDisplayMode() ? (
<Form.Item name={['planDefaults', 'jangsingProcessingRequired']} valuePropName="checked"> <Alert
<Checkbox> </Checkbox> showIcon
</Form.Item> type="info"
<Form.Item name={['planDefaults', 'autoDeployToMain']} valuePropName="checked"> message="아이폰/아이패드는 홈 화면에 추가한 PWA에서만 웹 푸시를 등록할 수 있습니다."
<Checkbox>main </Checkbox> />
</Form.Item> ) : null}
<Form.Item name={['planDefaults', 'openEditorAfterCreate']} valuePropName="checked"> </div>
<Checkbox> </Checkbox> ),
</Form.Item> },
</Card> {
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="업무일지 자동화"> return (
<Form.Item name={['worklogAutomation', 'autoCreateDailyWorklog']} valuePropName="checked"> <div key={storageItemKey} className="shared-app-settings-page__storage-item">
<Checkbox> </Checkbox> <div className="shared-app-settings-page__storage-head">
</Form.Item> <div>
<Form.Item label="생성 시각" name={['worklogAutomation', 'dailyCreateTime']}> <Text strong>{entry.label}</Text>
<Input placeholder="18:00" /> <Text type="secondary">{entry.description}</Text>
</Form.Item> </div>
<Form.Item name={['worklogAutomation', 'includeScreenshots']} valuePropName="checked"> <Text type="secondary">{entry.scope === 'local' ? 'localStorage' : 'sessionStorage'}</Text>
<Checkbox> </Checkbox> </div>
</Form.Item> <Text code>{entry.storageKey}</Text>
<Form.Item name={['worklogAutomation', 'includeChangedFiles']} valuePropName="checked"> <Input.TextArea
<Checkbox> </Checkbox> rows={4}
</Form.Item> value={storageDrafts[storageItemKey] ?? ''}
<Form.Item name={['worklogAutomation', 'includeCommandLogs']} valuePropName="checked"> placeholder="값이 없으면 비워 둡니다."
<Checkbox> </Checkbox> onChange={(event) => {
</Form.Item> handleStorageDraftChange(entry, event.target.value);
<Form.Item label="템플릿" name={['worklogAutomation', 'template']}> }}
<Select />
options={[ <div className="shared-app-settings-page__inline-actions">
{ value: 'simple', label: '간단' }, <Button size="small" onClick={() => handleReloadStorageEntry(entry)}>
{ value: 'detailed', label: '상세' },
]} </Button>
/> <Button size="small" type="primary" onClick={() => handleSaveStorageEntry(entry)}>
</Form.Item>
</Card> </Button>
<Button size="small" danger onClick={() => handleClearStorageEntry(entry)}>
<Card size="small" title="비용 표시 / 단축키">
<Form.Item label="백만 토큰당 기본 비용" name={['planCost', 'baseCostPerMillionTokens']}> </Button>
<InputNumber min={100} max={1000000} style={{ width: '100%' }} /> </div>
</Form.Item> </div>
<Form.Item label="재시도 비용 배수(%)" name={['planCost', 'retryCostMultiplierPercent']}> );
<InputNumber min={0} max={500} style={{ width: '100%' }} /> })}
</Form.Item> </div>
<Form.Item label="시간 비용 배수(%)" name={['planCost', 'hourlyCostMultiplierPercent']}> </div>
<InputNumber min={0} max={500} style={{ width: '100%' }} /> ),
</Form.Item> }))}
<Form.Item label="시간 단위" name={['planCost', 'timeCostUnit']}> />
<Select options={PLAN_COST_TIME_UNIT_OPTIONS} /> </div>
</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>
</Form> </Form>
<Text type="secondary">
.
</Text>
</div> </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() { export async function fetchChatRuntimeSnapshot() {
const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>('/runtime'); const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>('/runtime');
return response.item; return response.item;
@@ -2576,6 +2648,8 @@ export type ChatShareSnapshot = {
processingCount: number; processingCount: number;
unansweredCount: number; unansweredCount: number;
}; };
oldestLoadedMessageId?: number | null;
hasOlderMessages?: boolean;
promptTarget?: { promptTarget?: {
sourceMessageId: number; sourceMessageId: number;
promptIndex: number; promptIndex: number;
@@ -2799,6 +2873,8 @@ export async function fetchChatShareSnapshot(
rooms?: ChatShareRoomSummary[]; rooms?: ChatShareRoomSummary[];
activeSessionId?: string | null; activeSessionId?: string | null;
roomRequestCounts?: ChatShareSnapshot['roomRequestCounts']; roomRequestCounts?: ChatShareSnapshot['roomRequestCounts'];
oldestLoadedMessageId?: number | null;
hasOlderMessages?: boolean;
promptTarget?: ChatShareSnapshot['promptTarget']; promptTarget?: ChatShareSnapshot['promptTarget'];
refreshedAt: string; refreshedAt: string;
}>( }>(
@@ -2906,6 +2982,11 @@ export async function fetchChatShareSnapshot(
unansweredCount: Number.isFinite(response.roomRequestCounts.unansweredCount) ? response.roomRequestCounts.unansweredCount : 0, unansweredCount: Number.isFinite(response.roomRequestCounts.unansweredCount) ? response.roomRequestCounts.unansweredCount : 0,
} }
: undefined, : undefined,
oldestLoadedMessageId:
Number.isFinite(response.oldestLoadedMessageId) && Number(response.oldestLoadedMessageId) > 0
? Number(response.oldestLoadedMessageId)
: null,
hasOlderMessages: response.hasOlderMessages === true,
promptTarget: response.promptTarget ?? null, promptTarget: response.promptTarget ?? null,
refreshedAt: response.refreshedAt, refreshedAt: response.refreshedAt,
} satisfies ChatShareSnapshot; } satisfies ChatShareSnapshot;

View File

@@ -33,8 +33,10 @@
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overscroll-behavior-x: none;
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
scrollbar-gutter: stable; scrollbar-gutter: stable;
touch-action: pan-y;
scroll-padding-bottom: calc(var(--chat-share-page-bottom-padding) + var(--chat-share-page-active-safe-bottom)); scroll-padding-bottom: calc(var(--chat-share-page-bottom-padding) + var(--chat-share-page-active-safe-bottom));
padding: padding:
calc(var(--chat-share-page-top-padding) + var(--chat-share-page-safe-top)) calc(var(--chat-share-page-top-padding) + var(--chat-share-page-safe-top))
@@ -912,6 +914,18 @@
flex: 0 0 auto; 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 { .chat-share-page__live-dot {
width: 10px; width: 10px;
height: 10px; height: 10px;
@@ -1412,6 +1426,48 @@
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%); 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 { .chat-share-page__room-settings-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1541,9 +1597,10 @@
.chat-share-page__process-inspector--fullscreen { .chat-share-page__process-inspector--fullscreen {
inset: 0; inset: 0;
width: 100vw; width: auto;
max-height: 100dvh; height: auto;
height: 100dvh; max-width: none;
max-height: none;
border-radius: 0; border-radius: 0;
border-width: 0; border-width: 0;
} }
@@ -1672,6 +1729,49 @@
overflow: auto; 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 { .chat-share-page__process-inspector-section {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -2385,6 +2485,10 @@
padding-top: calc(14px + env(safe-area-inset-top, 0px)); 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 { .chat-share-page__program-app-shell--system-chat-room {
padding: 0; 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 { 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 { 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 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 { createPortal } from 'react-dom';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { FullscreenPreviewModal } from '../../../components/previewer'; import { FullscreenPreviewModal } from '../../../components/previewer';
@@ -43,6 +43,7 @@ import {
completeChatShareManualBadge, completeChatShareManualBadge,
createChatShareRoom, createChatShareRoom,
fetchChatConversationDetail, fetchChatConversationDetail,
fetchChatShareConversationDetail,
fetchChatConversations, fetchChatConversations,
deleteChatShareRoom, deleteChatShareRoom,
fetchChatShareRuntimeSnapshot, fetchChatShareRuntimeSnapshot,
@@ -82,6 +83,7 @@ import type {
import { isPromptResolved } from '../mainChatPanel/promptState'; import { isPromptResolved } from '../mainChatPanel/promptState';
import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi'; import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi';
import { copyTextToClipboard } from '../../../utils/clipboard'; import { copyTextToClipboard } from '../../../utils/clipboard';
import { isTypingTarget } from '../mainView/utils';
import { applyViewportCssVars, scheduleViewportRecoverySync } from '../viewportCssVars'; import { applyViewportCssVars, scheduleViewportRecoverySync } from '../viewportCssVars';
import { isPreviewRuntime } from '../previewRuntime'; import { isPreviewRuntime } from '../previewRuntime';
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../pwa/installManifest'; 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_STORAGE_KEY_PREFIX = 'codex-live-share-room-snapshot:v1';
const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6; const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6;
const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000; 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 = [ const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [
{ value: 'always', label: '매번 묻기', minutes: 0 }, { value: 'always', label: '매번 묻기', minutes: 0 },
{ value: '5', label: '5분 유지', minutes: 5 }, { value: '5', label: '5분 유지', minutes: 5 },
@@ -177,6 +185,18 @@ type ShareSearchResult = {
type ShareSearchIndexedResult = ShareSearchResult & { type ShareSearchIndexedResult = ShareSearchResult & {
searchText: string; searchText: string;
}; };
type ShareEdgeGestureTracking =
| {
startX: number;
startY: number;
direction: 'back';
}
| {
startX: number;
startY: number;
direction: 'apps';
opened: boolean;
};
type ShareSearchPanelMode = 'all' | 'apps'; type ShareSearchPanelMode = 'all' | 'apps';
type ShareWorkServerVersionStatus = 'latest' | 'unknown' | 'update-available' | 'build-required'; type ShareWorkServerVersionStatus = 'latest' | 'unknown' | 'update-available' | 'build-required';
type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied'; type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied';
@@ -1883,6 +1903,31 @@ function isMobileShareInputTarget(target: EventTarget | null): target is HTMLEle
return Boolean(inputTarget) && isSnapshotDeferrableFocusTarget(inputTarget); 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() { function getDefaultProgramMinimizedPosition() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return { x: PROGRAM_MINIMIZED_VIEWPORT_PADDING, y: PROGRAM_MINIMIZED_VIEWPORT_PADDING }; 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) { function buildShareVisibleText(text: string) {
return stripHiddenPreviewTags(extractAttachmentPreviewUrls(text).strippedText).trim(); return stripHiddenPreviewTags(extractAttachmentPreviewUrls(text).strippedText).trim();
} }
@@ -4014,8 +4091,11 @@ export function ChatSharePage() {
const [snapshot, setSnapshot] = useState<ChatShareSnapshot | null>(initialCachedSnapshot); const [snapshot, setSnapshot] = useState<ChatShareSnapshot | null>(initialCachedSnapshot);
const [requestedRoomSessionId, setRequestedRoomSessionId] = useState<string>(initialRequestedRoomSessionId); const [requestedRoomSessionId, setRequestedRoomSessionId] = useState<string>(initialRequestedRoomSessionId);
const requestedRoomSessionIdRef = useRef(requestedRoomSessionId); const requestedRoomSessionIdRef = useRef(requestedRoomSessionId);
const skipNextRequestedRoomRefreshRef = useRef(false);
const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null); const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null);
const [, setIsRefreshing] = useState(false); const [, setIsRefreshing] = useState(false);
const [isLoadingFullSnapshot, setIsLoadingFullSnapshot] = useState(false);
const [isLoadingOlderShareHistory, setIsLoadingOlderShareHistory] = useState(false);
const [isLiveConnected, setIsLiveConnected] = useState(false); const [isLiveConnected, setIsLiveConnected] = useState(false);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [requiresAccessPin, setRequiresAccessPin] = useState(false); const [requiresAccessPin, setRequiresAccessPin] = useState(false);
@@ -4848,12 +4928,6 @@ export function ChatSharePage() {
setRequiresAccessPin(false); setRequiresAccessPin(false);
setAccessPinSubmitError(''); setAccessPinSubmitError('');
if (initialLoad && nextSnapshot.detailLevel === 'initial') {
window.setTimeout(() => {
void refreshSnapshot({ silent: true });
}, 0);
}
return true; return true;
} catch (error) { } catch (error) {
if (error instanceof ChatApiError && error.status === 401 && (error.code === 'share_pin_required' || error.code === 'share_pin_invalid')) { 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; snapshotRefreshPromiseRef.current = refreshTask;
return refreshTask; return refreshTask;
}, [normalizedToken]); }, [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) => { const handleDeleteShareRoom = useCallback(async (room: ChatShareRoomSummary) => {
if (!normalizedToken || !room.sessionId || isDeletingRoom) { if (!normalizedToken || !room.sessionId || isDeletingRoom) {
return; return;
@@ -5619,6 +5729,39 @@ export function ChatSharePage() {
captureTarget.setPointerCapture(event.pointerId); 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) => { const handleRestoreProgram = useCallback((targetKey: string) => {
let restoredTarget: ShareProgramTarget | null = null; let restoredTarget: ShareProgramTarget | null = null;
@@ -5693,8 +5836,13 @@ export function ChatSharePage() {
size="small" size="small"
icon={<AppstoreOutlined />} icon={<AppstoreOutlined />}
className="chat-share-page__program-minimized-button" className="chat-share-page__program-minimized-button"
onClick={() => { onPointerDown={handleProgramMinimizedActionPointerDown}
handleRestoreProgram(item.target.key); 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" className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close"
icon={<CloseOutlined />} icon={<CloseOutlined />}
aria-label="프로그램 닫기" aria-label="프로그램 닫기"
onClick={() => { onPointerDown={handleProgramMinimizedActionPointerDown}
handleCloseMinimizedProgram(item.target.key); onClick={(event: ReactMouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
runProgramMinimizedActionAfterPointerCycle(() => {
handleCloseMinimizedProgram(item.target.key);
});
}} }}
/> />
</div> </div>
@@ -5979,6 +6132,11 @@ export function ChatSharePage() {
return; return;
} }
if (skipNextRequestedRoomRefreshRef.current) {
skipNextRequestedRoomRefreshRef.current = false;
return;
}
void refreshSnapshot({ silent: true }); void refreshSnapshot({ silent: true });
}, [normalizedToken, refreshSnapshot, requestedRoomSessionId]); }, [normalizedToken, refreshSnapshot, requestedRoomSessionId]);
useEffect(() => { useEffect(() => {
@@ -6001,10 +6159,11 @@ export function ChatSharePage() {
} }
requestedRoomSessionIdRef.current = stabilizedRoomSessionId; requestedRoomSessionIdRef.current = stabilizedRoomSessionId;
skipNextRequestedRoomRefreshRef.current = snapshot?.detailLevel === 'initial';
writeStoredShareLastRoomSessionId(normalizedToken, stabilizedRoomSessionId); writeStoredShareLastRoomSessionId(normalizedToken, stabilizedRoomSessionId);
writeShareRoomSessionIdToLocation(stabilizedRoomSessionId, 'replace'); writeShareRoomSessionIdToLocation(stabilizedRoomSessionId, 'replace');
setRequestedRoomSessionId(stabilizedRoomSessionId); setRequestedRoomSessionId(stabilizedRoomSessionId);
}, [activeShareRoomSessionId, normalizedToken, shareRooms]); }, [activeShareRoomSessionId, normalizedToken, shareRooms, snapshot?.detailLevel]);
useEffect(() => { useEffect(() => {
if (!requestedRoomSessionId) { if (!requestedRoomSessionId) {
@@ -6534,7 +6693,7 @@ export function ChatSharePage() {
if (cachedSnapshot) { if (cachedSnapshot) {
setSnapshot(cachedSnapshot); setSnapshot(cachedSnapshot);
} }
setIsRoomSwitching(true); setIsRoomSwitching(!cachedSnapshot);
requestedRoomSessionIdRef.current = normalizedSessionId; requestedRoomSessionIdRef.current = normalizedSessionId;
writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId); writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId);
writeShareRoomSessionIdToLocation(normalizedSessionId, 'push'); writeShareRoomSessionIdToLocation(normalizedSessionId, 'push');
@@ -7740,7 +7899,15 @@ export function ChatSharePage() {
</div> </div>
)} )}
</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"> <section className="chat-share-page__process-inspector-section chat-share-page__process-inspector-section--checklist">
<div className="chat-share-page__process-inspector-section-head"> <div className="chat-share-page__process-inspector-section-head">
<Text strong> </Text> <Text strong> </Text>
@@ -7916,8 +8083,10 @@ export function ChatSharePage() {
try { try {
const roomSnapshot = await fetchChatShareSnapshot(normalizedToken, { const roomSnapshot = await fetchChatShareSnapshot(normalizedToken, {
sessionId: normalizedSessionId, sessionId: normalizedSessionId,
view: 'initial',
sharePin, sharePin,
}); });
writeStoredShareRoomSnapshot(normalizedToken, roomSnapshot);
setShareRoomPendingCountsBySessionId((current) => { setShareRoomPendingCountsBySessionId((current) => {
const nextCounts = resolveShareRoomPendingCounts(roomSnapshot); const nextCounts = resolveShareRoomPendingCounts(roomSnapshot);
@@ -8394,6 +8563,7 @@ export function ChatSharePage() {
? renderEmbeddedSharePlayApp(programTarget.appId, closeProgramTarget, normalizedToken) ? renderEmbeddedSharePlayApp(programTarget.appId, closeProgramTarget, normalizedToken)
: null; : null;
const isServerCommandDrawerOpen = programTarget?.appId === 'server-command'; const isServerCommandDrawerOpen = programTarget?.appId === 'server-command';
const isAppSettingsDrawerOpen = programTarget?.appId === 'app-settings';
const indexedContentSearchResults = useMemo<ShareSearchIndexedResult[]>(() => { const indexedContentSearchResults = useMemo<ShareSearchIndexedResult[]>(() => {
const results: 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: ( label: (
<span className="chat-share-page__settings-item"> <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 className="chat-share-page__settings-item-description">
. , , .
</span> </span>
</span> </span>
), ),
icon: <PlusOutlined />, icon: <SettingOutlined />,
}, },
] ]
: []), : []),
@@ -8898,11 +9068,12 @@ export function ChatSharePage() {
allowedPlayAppEntries.length, allowedPlayAppEntries.length,
canSendMessage, canSendMessage,
canOpenSharedRoomSettings, canOpenSharedRoomSettings,
canCreateSharedRooms,
hasWorkServerCommandApp, hasWorkServerCommandApp,
isClearingConversation, isClearingConversation,
normalizedToken, normalizedToken,
selectedTokenUsageSetting, selectedTokenUsageSetting,
shareAllowedAppIdSet,
sharePermissionSet,
shareWorkServerCommand, shareWorkServerCommand,
snapshot?.conversation.title, snapshot?.conversation.title,
selectedShareRoomSessionId, selectedShareRoomSessionId,
@@ -8911,6 +9082,12 @@ export function ChatSharePage() {
tokenUsageOverview.fiveHourCountdownLabel, tokenUsageOverview.fiveHourCountdownLabel,
], ],
); );
const openShareAppsPanel = useCallback(() => {
setSearchPanelMode('apps');
setSearchKeyword('');
setIsShareRoomListVisible(false);
setIsSearchOpen(true);
}, []);
const handleShareHeaderSettingsClick = useCallback<NonNullable<MenuProps['onClick']>>( const handleShareHeaderSettingsClick = useCallback<NonNullable<MenuProps['onClick']>>(
({ key }) => { ({ key }) => {
if (key === 'conversation-search') { if (key === 'conversation-search') {
@@ -8926,9 +9103,7 @@ export function ChatSharePage() {
} }
if (key === 'conversation-apps') { if (key === 'conversation-apps') {
setSearchPanelMode('apps'); openShareAppsPanel();
setSearchKeyword('');
setIsSearchOpen(true);
return; return;
} }
@@ -8942,8 +9117,8 @@ export function ChatSharePage() {
return; return;
} }
if (key === 'conversation-room-create') { if (key === 'conversation-personal-settings') {
openCreateRoomDialog(); openProgramTarget(buildShareManagementProgramTarget('app-settings', '개인설정'));
return; return;
} }
@@ -8956,8 +9131,127 @@ export function ChatSharePage() {
void handleClearConversation(); 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']>( const shareExpandModeMenuItems = useMemo<MenuProps['items']>(
() => [ () => [
{ {
@@ -9363,6 +9657,28 @@ export function ChatSharePage() {
<Text type="secondary" className="chat-share-page__header-summary"> <Text type="secondary" className="chat-share-page__header-summary">
{headerSummaryLabel} {headerSummaryLabel}
</Text> </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>
<div className="chat-share-page__section-actions"> <div className="chat-share-page__section-actions">
{canToggleShareRoomList ? ( {canToggleShareRoomList ? (
@@ -10724,8 +11040,36 @@ export function ChatSharePage() {
</div> </div>
) : null} ) : null}
</Drawer> </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 <FullscreenPreviewModal
open={Boolean(programTarget) && !isServerCommandDrawerOpen} open={Boolean(programTarget) && !isServerCommandDrawerOpen && !isAppSettingsDrawerOpen}
title={programTarget?.label ?? '공유 프로그램'} title={programTarget?.label ?? '공유 프로그램'}
meta={programTarget?.meta ?? '공유 토큰 실행'} meta={programTarget?.meta ?? '공유 토큰 실행'}
actions={ actions={
@@ -10825,13 +11169,6 @@ export function ChatSharePage() {
} }
/> />
</div> </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"> <div key={`${programTarget.key}:${programReloadKey}`} className="chat-share-page__program-app-shell">
<ChatPreviewBody <ChatPreviewBody