chore: exclude local resource artifacts from main sync

This commit is contained in:
2026-05-15 10:16:45 +09:00
parent 442879313f
commit d38d022872
504 changed files with 17074 additions and 3642 deletions

0
src/app/main/AppShell.tsx Executable file → Normal file
View File

View File

@@ -125,6 +125,7 @@ export function AutomationTypeManagementPage() {
setSelectedAutomationTypeId(null);
setDetailMode('detail');
setMaximizedPane('none');
setMobileView('edit');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
@@ -134,12 +135,14 @@ export function AutomationTypeManagementPage() {
setSelectedAutomationTypeId(automationTypeId);
setDetailMode('detail');
setMaximizedPane('none');
setMobileView('edit');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
setMaximizedPane('none');
setMobileView('edit');
};
const handleDelete = async () => {
@@ -221,7 +224,7 @@ export function AutomationTypeManagementPage() {
<div
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
}`}
}${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`}
>
{detailMode === 'list' ? (
<Card

View File

@@ -1 +1,36 @@
@import './ManagementPage.shared.css';
.chat-default-context-management-page .chat-default-context-management-page__meta-grid {
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
}
.chat-default-context-management-page .chat-default-context-management-page__meta-item--name {
grid-column: auto;
}
.chat-default-context-management-page .chat-default-context-management-page__pane-toggle-button.ant-btn {
width: 36px;
min-width: 36px;
height: 36px;
padding-inline: 0;
}
@media (max-width: 960px) {
.chat-default-context-management-page .chat-default-context-management-page__meta-grid {
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.chat-default-context-management-page .chat-default-context-management-page__meta-item--name {
grid-column: 1;
}
.chat-default-context-management-page .chat-type-management-page__meta-item--enabled {
justify-self: stretch;
}
.chat-default-context-management-page .chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
justify-content: flex-start;
}
}

View File

@@ -1,14 +1,18 @@
import {
ArrowDownOutlined,
ArrowUpOutlined,
ArrowsAltOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
SaveOutlined,
ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
deleteChatDefaultContext,
@@ -51,6 +55,7 @@ function toFormValue(record: ChatDefaultContextRecord | null): ChatDefaultContex
export function ChatDefaultContextManagementPage() {
const { hasAccess } = useTokenAccess();
const [searchParams] = useSearchParams();
const {
defaultContexts,
chatTypeDefaults,
@@ -78,6 +83,7 @@ export function ChatDefaultContextManagementPage() {
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
[defaultContexts, selectedContextId],
);
const requestedContextId = searchParams.get('contextId')?.trim() ?? '';
const shouldRenderServerList = hasLoadedFromServer && !contextSettingsErrorMessage;
const isServerDataReadyForEditing = hasLoadedFromServer && !contextSettingsErrorMessage && storeSource === 'server';
@@ -89,6 +95,22 @@ export function ChatDefaultContextManagementPage() {
setSelectedContextId(defaultContexts[0]?.id ?? null);
}, [defaultContexts, selectedContextId]);
useEffect(() => {
if (!requestedContextId) {
return;
}
if (!defaultContexts.some((item) => item.id === requestedContextId)) {
return;
}
setIsCreating(false);
setSelectedContextId(requestedContextId);
setDetailMode('detail');
setMaximizedPane('none');
setMobileView('edit');
}, [defaultContexts, requestedContextId]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
@@ -119,11 +141,68 @@ export function ChatDefaultContextManagementPage() {
};
}, []);
const handleReload = async () => {
setIsReloading(true);
setSaveErrorMessage('');
try {
await reload();
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '공통 문맥 재조회에 실패했습니다.');
} finally {
setIsReloading(false);
}
};
const moveDefaultContextInList = async (contextId: string, direction: 'up' | 'down') => {
const currentIndex = defaultContexts.findIndex((item) => item.id === contextId);
if (currentIndex < 0) {
return;
}
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= defaultContexts.length) {
return;
}
const reorderedDefaultContexts = [...defaultContexts];
const [movedItem] = reorderedDefaultContexts.splice(currentIndex, 1);
reorderedDefaultContexts.splice(targetIndex, 0, movedItem);
const nextDefaultContexts = reorderedDefaultContexts.map((item, index) => ({
...item,
sortOrder: index + 1,
}));
setSaveErrorMessage('');
try {
const savedStore = await setStore({
defaultContexts: nextDefaultContexts,
chatTypeDefaults,
roomContexts,
});
setSelectedContextId((currentSelectedId) => {
if (currentSelectedId && savedStore.defaultContexts.some((item) => item.id === currentSelectedId)) {
return currentSelectedId;
}
return contextId;
});
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '공통 문맥 순서 변경에 실패했습니다.');
}
};
const openCreateForm = () => {
setIsCreating(true);
setSelectedContextId(null);
setDetailMode('detail');
setMaximizedPane('none');
setMobileView('edit');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
@@ -133,12 +212,14 @@ export function ChatDefaultContextManagementPage() {
setSelectedContextId(contextId);
setDetailMode('detail');
setMaximizedPane('none');
setMobileView('edit');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
setMaximizedPane('none');
setMobileView('edit');
};
const handleDelete = async () => {
@@ -146,7 +227,7 @@ export function ChatDefaultContextManagementPage() {
return;
}
if (!window.confirm(`"${selectedContext.title}" 기본 유형을 삭제할까요?`)) {
if (!window.confirm(`"${selectedContext.title}" 공통 문맥을 삭제할까요?`)) {
return;
}
@@ -168,12 +249,12 @@ export function ChatDefaultContextManagementPage() {
if (!hasAccess) {
return (
<Card title="기본 유형 관리" className="chat-type-management-page">
<Card title="공통 문맥 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 기본 유형을 관리하세요."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 공통 문맥을 관리하세요."
/>
</Card>
);
@@ -181,18 +262,32 @@ export function ChatDefaultContextManagementPage() {
return (
<div
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
maximizedPane !== 'none' ? ' chat-type-management-page--pane-maximized' : ''
className={`chat-type-management-page chat-default-context-management-page${
detailMode === 'detail' ? ' chat-type-management-page--detail' : ''
}${maximizedPane !== 'none' ? ' chat-type-management-page--pane-maximized' : ''}${
isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''
}`}
>
{detailMode === 'list' ? (
<Card
title="기본 유형 관리"
title="공통 문맥 관리"
className="chat-type-management-page__card"
extra={
<Button icon={<PlusOutlined />} onClick={openCreateForm} disabled={!isServerDataReadyForEditing}>
</Button>
<Space size={8} wrap>
<Button
shape="circle"
icon={<ReloadOutlined />}
aria-label="새로고침"
title="새로고침"
onClick={() => {
void handleReload();
}}
loading={isReloading}
/>
<Button icon={<PlusOutlined />} onClick={openCreateForm} disabled={!isServerDataReadyForEditing}>
</Button>
</Space>
}
>
<div className="chat-type-management-page__list">
@@ -202,19 +297,14 @@ export function ChatDefaultContextManagementPage() {
<Alert
showIcon
type="error"
message="기본 유형 목록을 서버에서 불러오지 못했습니다."
message="공통 문맥 목록을 서버에서 불러오지 못했습니다."
description={
<Space direction="vertical" size={8}>
<Text>{contextSettingsErrorMessage}</Text>
<Space size={8} wrap>
<Button
onClick={() => {
setIsReloading(true);
void reload()
.catch(() => undefined)
.finally(() => {
setIsReloading(false);
});
void handleReload().catch(() => undefined);
}}
loading={isReloading}
>
@@ -234,7 +324,7 @@ export function ChatDefaultContextManagementPage() {
/>
) : null}
<div className="chat-type-management-page__list-header">
<Title level={5}> </Title>
<Title level={5}> </Title>
<Space size={8} wrap>
<Text type="secondary">{shouldRenderServerList ? `${defaultContexts.length}` : '서버 확인 전'}</Text>
{isLoading ? <Text type="secondary"> </Text> : null}
@@ -249,50 +339,76 @@ export function ChatDefaultContextManagementPage() {
{shouldRenderServerList && defaultContexts.length > 0 ? (
<List
dataSource={defaultContexts}
renderItem={(item) => (
<List.Item
className={
item.id === selectedContextId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item'
}
onClick={() => {
openDetail(item.id);
}}
actions={[
<Button
key="edit"
type="text"
icon={<EditOutlined />}
onClick={(event) => {
event.stopPropagation();
openDetail(item.id);
}}
/>,
]}
>
<div className="chat-type-management-page__item-main">
<Space size={[8, 8]} wrap>
<Text strong>{item.title}</Text>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
</Space>
<div className="chat-type-management-page__item-description">
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
renderItem={(item) => {
const itemIndex = defaultContexts.findIndex((context) => context.id === item.id);
const canMoveUp = itemIndex > 0;
const canMoveDown = itemIndex >= 0 && itemIndex < defaultContexts.length - 1;
return (
<List.Item
className={
item.id === selectedContextId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item'
}
>
<div className="chat-type-management-page__item-main">
<Space size={[8, 8]} wrap>
<Text strong>{item.title}</Text>
<Tag color="cyan"> {item.sortOrder}</Tag>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
</Space>
<div className="chat-type-management-page__item-description">
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
</div>
<div className="chat-type-management-page__item-actions">
<Button
icon={<ArrowUpOutlined />}
disabled={!isServerDataReadyForEditing || !canMoveUp}
onClick={(event) => {
event.stopPropagation();
void moveDefaultContextInList(item.id, 'up');
}}
>
</Button>
<Button
icon={<ArrowDownOutlined />}
disabled={!isServerDataReadyForEditing || !canMoveDown}
onClick={(event) => {
event.stopPropagation();
void moveDefaultContextInList(item.id, 'down');
}}
>
</Button>
<Button
type="default"
icon={<EditOutlined />}
disabled={!isServerDataReadyForEditing}
onClick={(event) => {
event.stopPropagation();
openDetail(item.id);
}}
>
</Button>
</div>
</div>
</div>
</List.Item>
)}
</List.Item>
);
}}
/>
) : isLoading && !hasLoadedFromServer ? (
<Alert showIcon type="info" message="기본 유형 목록을 서버에서 불러오는 중입니다." />
<Alert showIcon type="info" message="공통 문맥 목록을 서버에서 불러오는 중입니다." />
) : (
<Empty
description={
contextSettingsErrorMessage
? '서버 동기화 실패 상태입니다. 재조회 후 다시 확인해 주세요.'
: hasLoadedFromServer
? '등록된 기본 유형이 없습니다.'
: '서버 기준 기본 유형을 아직 확인하지 못했습니다.'
? '등록된 공통 문맥이 없습니다.'
: '서버 기준 공통 문맥을 아직 확인하지 못했습니다.'
}
/>
)}
@@ -301,12 +417,22 @@ export function ChatDefaultContextManagementPage() {
</Card>
) : (
<Card
title={isCreating ? '기본 유형 등록' : '기본 유형 상세'}
title={isCreating ? '공통 문맥 등록' : '공통 문맥 상세'}
className={`chat-type-management-page__card${
maximizedPane !== 'none' ? ' chat-type-management-page__card--pane-maximized' : ''
}`}
extra={
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Button
shape="circle"
icon={<ReloadOutlined />}
aria-label="새로고침"
title="새로고침"
onClick={() => {
void handleReload();
}}
loading={isReloading}
/>
<Button
type="primary"
shape="circle"
@@ -347,7 +473,7 @@ export function ChatDefaultContextManagementPage() {
<Alert
showIcon
type="warning"
message="서버 최신 기본 유형을 확인한 뒤에만 수정할 수 있습니다."
message="서버 최신 공통 문맥을 확인한 뒤에만 수정할 수 있습니다."
description="부분 목록이나 오래된 상태로 저장해 서버 값이 덮어써지는 경로를 막기 위해, 서버 동기화가 완료되기 전에는 저장을 제한합니다."
/>
) : null}
@@ -372,7 +498,7 @@ export function ChatDefaultContextManagementPage() {
setSelectedContextId(savedContext?.id ?? null);
setDetailMode('detail');
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '기본 유형 저장에 실패했습니다.');
setSaveErrorMessage(error instanceof Error ? error.message : '공통 문맥 저장에 실패했습니다.');
}
}}
>
@@ -380,12 +506,16 @@ export function ChatDefaultContextManagementPage() {
<Input />
</Form.Item>
<div className="chat-type-management-page__editor-scroll">
<div className={`chat-type-management-page__meta-grid${maximizedPane !== 'none' ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<div
className={`chat-type-management-page__meta-grid chat-default-context-management-page__meta-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__meta-grid--hidden' : ''
}`}
>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="기본 유형명"
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name chat-default-context-management-page__meta-item--name"
label="공통 문맥명"
name="title"
rules={[{ required: true, message: '기본 유형명을 입력하세요.' }]}
rules={[{ required: true, message: '공통 문맥명을 입력하세요.' }]}
>
<Input placeholder="예: 모바일 검증 공통 규칙" />
</Form.Item>
@@ -398,45 +528,52 @@ export function ChatDefaultContextManagementPage() {
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : null}
<div
className={`chat-type-management-page__markdown-field${
isMobileViewport ? ' chat-type-management-page__markdown-field--mobile-active' : ''
}`}
>
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
{isMobileViewport ? null : (
<Space size={8} wrap>
<Button
className="chat-default-context-management-page__pane-toggle-button"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
title={maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
/>
<Button
className="chat-default-context-management-page__pane-toggle-button"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
/>
</Space>
)}
</div>
@@ -456,12 +593,25 @@ export function ChatDefaultContextManagementPage() {
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
className="chat-default-context-management-page__pane-toggle-button"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
title={maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
/>
) : null}
</div>
<Form.Item name="content" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={'## 적용 기준\n- 기본 유형의 공통 규칙을 Markdown으로 정의하세요.'}
placeholder={'## 적용 기준\n- 공통 문맥의 규칙을 Markdown으로 정의하세요.'}
/>
</Form.Item>
</div>
@@ -477,6 +627,19 @@ export function ChatDefaultContextManagementPage() {
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
className="chat-default-context-management-page__pane-toggle-button"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
/>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.content !== next.content}>
@@ -486,7 +649,7 @@ export function ChatDefaultContextManagementPage() {
return content ? (
<MarkdownPreviewContent content={content} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 기본 유형 본문이 없습니다." />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 공통 문맥 본문이 없습니다." />
);
}}
</Form.Item>

View File

@@ -1,6 +1,8 @@
// @ts-nocheck
import { useEffect, useRef } from 'react';
import { useAppConfig } from './appConfig';
import { isPreviewRuntime } from './previewRuntime';
import { getOrCreateClientId } from './clientIdentity';
import {
createNotificationMessage,
sendClientNotification,
@@ -70,6 +72,10 @@ async function tryShowLocalChatNotification(args: {
threadId: string;
data: Record<string, string>;
}) {
if (shouldSuppressChatNotificationWhenAppOpen()) {
return;
}
await showLocalClientNotification({
title: args.title,
body: args.body,
@@ -152,6 +158,22 @@ function shouldPollConversationNotifications() {
return false;
}
function shouldSuppressChatNotificationWhenAppOpen() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return false;
}
if (document.visibilityState === 'hidden') {
return false;
}
if (typeof document.hasFocus === 'function') {
return document.hasFocus();
}
return true;
}
function getConversationActivityTime(item: { lastMessageAt?: string | null; updatedAt?: string | null }) {
const candidate = item.lastMessageAt || item.updatedAt || '';
const parsed = candidate ? Date.parse(candidate) : Number.NaN;
@@ -203,6 +225,7 @@ export function ChatNotificationBridgeV2() {
const notificationData = {
category: 'chat',
priority,
suppressIfVisible: 'true',
sessionId: targetSessionId,
conversationTitle: resolvedConversationTitle,
targetUrl: linkUrl,
@@ -223,8 +246,16 @@ export function ChatNotificationBridgeV2() {
body,
threadId: `chat:${targetSessionId}`,
data: serializedNotificationData,
targetClientIds: (() => {
const clientId = getOrCreateClientId().trim();
return clientId ? [clientId] : undefined;
})(),
};
if (shouldSuppressChatNotificationWhenAppOpen()) {
return Promise.resolve(undefined);
}
return Promise.allSettled([
createNotificationMessage({
title,
@@ -260,8 +291,137 @@ export function ChatNotificationBridgeV2() {
.catch(() => undefined);
};
if (!appConfig.chat.receiveRoomNotifications) {
if (isPreviewRuntime() || !appConfig.chat.receiveRoomNotifications) {
return null;
}
useEffect(() => {
let cancelled = false;
const pollNotifications = async () => {
if (cancelled || !shouldPollConversationNotifications()) {
return;
}
try {
const conversations = await chatGateway.listConversations();
if (cancelled) {
return;
}
const candidates = selectNotificationPollingCandidates(conversations);
await Promise.all(
candidates.map(async (conversation) => {
const detail = await chatGateway.getConversationDetail(conversation.sessionId, { limit: 40 });
if (cancelled) {
return;
}
const latestCodexMessage = findLatestCodexMessage(detail.messages);
const latestCodexMessageId = latestCodexMessage?.id ?? 0;
const previousCodexMessageId = lastPolledCodexMessageIdBySessionRef.current[conversation.sessionId];
const questionText = findQuestionText(detail.messages, latestCodexMessage?.clientRequestId);
const latestFailedRequest = findLatestFailedRequest(detail.requests);
const failedRequestKey = latestFailedRequest
? `${latestFailedRequest.requestId}:${latestFailedRequest.updatedAt}:${latestFailedRequest.status}`
: '';
const previousFailedRequestKey = lastFailedRequestKeyBySessionRef.current[conversation.sessionId] ?? '';
lastPolledCodexMessageIdBySessionRef.current[conversation.sessionId] = latestCodexMessageId;
lastFailedRequestKeyBySessionRef.current[conversation.sessionId] = failedRequestKey;
if (!shouldNotifyWhileAway()) {
return;
}
if (
latestFailedRequest &&
failedRequestKey &&
previousFailedRequestKey &&
previousFailedRequestKey !== failedRequestKey
) {
const notificationKey = `failed:${conversation.sessionId}:${failedRequestKey}`;
if (!notifiedFailedJobKeysRef.current.includes(notificationKey)) {
notifiedFailedJobKeysRef.current = [...notifiedFailedJobKeysRef.current, notificationKey].slice(-80);
const failureDetail =
normalizeNotificationDetailText(latestFailedRequest.statusMessage) ||
normalizeNotificationDetailText(latestFailedRequest.responseText) ||
'요청이 실패했습니다.';
await createChatNotification({
targetSessionId: conversation.sessionId,
conversationTitle: conversation.title,
title: `${conversation.title || '현재 채팅방'} 실행 실패`,
body: createChatQuestionAnswerNotificationBody({
questionText: latestFailedRequest.userText,
answerText: failureDetail,
fallback: `${conversation.title || '현재 채팅방'}에서 실행이 실패했습니다.`,
}),
previewText: createChatQuestionOnlyNotificationPreview(
latestFailedRequest.userText,
`${conversation.title || '현재 채팅방'} 실행 실패`,
),
priority: 'high',
metadata: {
type: 'chat-request-failed',
requestId: latestFailedRequest.requestId,
questionText: latestFailedRequest.userText,
answerText: failureDetail,
},
});
}
}
if (
!conversation.hasUnreadResponse ||
!latestCodexMessage ||
latestCodexMessageId <= 0 ||
!previousCodexMessageId ||
latestCodexMessageId === previousCodexMessageId
) {
return;
}
await createChatNotification({
targetSessionId: conversation.sessionId,
conversationTitle: conversation.title,
title: `${conversation.title || '현재 채팅방'} 새 답변`,
body: createChatQuestionAnswerNotificationBody({
questionText,
answerText: latestCodexMessage.text,
fallback: `${conversation.title || '현재 채팅방'}에 새 답변이 도착했습니다.`,
}),
previewText: createChatQuestionOnlyNotificationPreview(questionText, `${conversation.title || '현재 채팅방'} 새 답변`),
priority: 'normal',
metadata: {
type: 'chat-response',
requestId: latestCodexMessage.clientRequestId ?? '',
questionText,
answerText: latestCodexMessage.text,
},
});
}),
);
} catch {
// Ignore polling errors and retry on the next interval.
}
};
void pollNotifications();
const timer = window.setInterval(() => {
void pollNotifications();
}, BACKGROUND_CONVERSATION_POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [appConfig.chat.receiveRoomNotifications]);
return null;
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useAppStore } from '../../store';
import { chatConnectionGateway, chatGateway } from './chatV2';
import type { ChatMessage, ChatViewContext } from './mainChatPanel/types';
@@ -15,28 +14,18 @@ function isStandaloneDisplayMode() {
);
}
function getLivePageFocusState() {
if (typeof document === 'undefined' || typeof document.hasFocus !== 'function') {
return 'focused' as const;
}
return document.hasFocus() ? ('focused' as const) : ('blurred' as const);
}
export function ChatRuntimeBridgeV2() {
const { currentPage, focusedComponentId } = useAppStore();
const location = useLocation();
const [, setMessages] = useState<ChatMessage[]>([]);
const sessionId = useMemo(() => {
if (typeof window === 'undefined') {
return '';
}
if (currentPage.topMenu !== 'chat') {
return '';
}
const currentUrl = new URL(window.location.href);
const pathname = currentUrl.pathname.replace(/\/+$/, '') || '/';
if (pathname !== '/chat/live') {
return '';
}
return currentUrl.searchParams.get('sessionId')?.trim() || '';
}, [currentPage.topMenu, location.pathname, location.search]);
const sessionId = useMemo(() => '', []);
const currentContext: ChatViewContext = useMemo(
() => ({
@@ -48,6 +37,7 @@ export function ChatRuntimeBridgeV2() {
isStandaloneMode: isStandaloneDisplayMode(),
pageVisibilityState:
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
pageFocusState: getLivePageFocusState(),
chatTypeId: null,
chatTypeLabel: '',
chatTypeDescription: '',

File diff suppressed because it is too large Load Diff

204
src/app/main/ChatTypeManagementPage.tsx Executable file → Normal file
View File

@@ -1,4 +1,6 @@
import {
ArrowDownOutlined,
ArrowUpOutlined,
ArrowsAltOutlined,
DeleteOutlined,
EditOutlined,
@@ -8,7 +10,22 @@ import {
ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
import {
Alert,
Button,
Card,
Checkbox,
Empty,
Form,
Input,
List,
Segmented,
Space,
Switch,
Tag,
Tooltip,
Typography,
} from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
@@ -34,6 +51,7 @@ const { Text, Title } = Typography;
type ChatTypeFormValue = {
id?: string;
name: string;
sortOrder: number;
description: string;
permissions: ChatPermissionRole[];
enabled: boolean;
@@ -41,6 +59,7 @@ type ChatTypeFormValue = {
const EMPTY_FORM_VALUE: ChatTypeFormValue = {
name: '',
sortOrder: 1,
description: '',
permissions: ['token-user'],
enabled: true,
@@ -54,6 +73,7 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
return {
id: chatType.id,
name: chatType.name,
sortOrder: chatType.sortOrder,
description: chatType.description,
permissions: chatType.permissions,
enabled: chatType.enabled,
@@ -62,7 +82,7 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
export function ChatTypeManagementPage() {
const { hasAccess } = useTokenAccess();
const { chatTypes, builtInChatTypes, customChatTypes, setChatTypes, isLoading, errorMessage, reload } = useChatTypeRegistry();
const { chatTypes, setChatTypes, setChatTypesLocal, isLoading, errorMessage, reload } = useChatTypeRegistry();
const {
defaultContexts,
chatTypeDefaults,
@@ -72,7 +92,7 @@ export function ChatTypeManagementPage() {
} = useChatContextSettingsRegistry();
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
const [mobileView, setMobileView] = useState<'default-contexts' | 'edit' | 'preview'>('edit');
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [isCreating, setIsCreating] = useState(false);
@@ -83,6 +103,12 @@ export function ChatTypeManagementPage() {
const [form] = Form.useForm<ChatTypeFormValue>();
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const isPaneMaximized = maximizedPane !== 'none';
const builtInChatTypes: ChatTypeRecord[] = [];
const customChatTypes = chatTypes;
const nextNewSortOrder = useMemo(
() => Math.max(0, ...customChatTypes.map((item) => item.sortOrder || 0)) + 1,
[customChatTypes],
);
const selectedChatType = useMemo(
() => customChatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
@@ -170,7 +196,11 @@ export function ChatTypeManagementPage() {
setDetailMode('detail');
setMaximizedPane('none');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
form.setFieldsValue({
...EMPTY_FORM_VALUE,
sortOrder: nextNewSortOrder,
});
setSelectedDefaultContextIds([]);
};
const openDetail = (chatTypeId: string) => {
@@ -201,11 +231,14 @@ export function ChatTypeManagementPage() {
try {
const savedSnapshot = await setChatTypes(nextChatTypes);
setSelectedChatTypeId(savedSnapshot.customChatTypes[0]?.id ?? null);
setSelectedChatTypeId(savedSnapshot.chatTypes[0]?.id ?? null);
setIsCreating(false);
setDetailMode('list');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
form.setFieldsValue({
...EMPTY_FORM_VALUE,
sortOrder: nextNewSortOrder,
});
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 삭제에 실패했습니다.');
} finally {
@@ -226,6 +259,50 @@ export function ChatTypeManagementPage() {
}
};
const moveChatTypeInList = async (chatTypeId: string, direction: 'up' | 'down') => {
const currentIndex = customChatTypes.findIndex((item) => item.id === chatTypeId);
if (currentIndex < 0) {
return;
}
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= customChatTypes.length) {
return;
}
const reorderedChatTypes = [...customChatTypes];
const [movedItem] = reorderedChatTypes.splice(currentIndex, 1);
reorderedChatTypes.splice(targetIndex, 0, movedItem);
const nextChatTypes = reorderedChatTypes.map((item, index) => ({
...item,
sortOrder: index + 1,
}));
setIsSaving(true);
setSaveErrorMessage('');
setChatTypesLocal(nextChatTypes);
try {
const savedSnapshot = await setChatTypes(nextChatTypes);
setSelectedChatTypeId((currentSelectedId) => {
if (currentSelectedId && savedSnapshot.chatTypes.some((item) => item.id === currentSelectedId)) {
return currentSelectedId;
}
return chatTypeId;
});
} catch (error) {
setChatTypesLocal(customChatTypes);
setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 순서 변경에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const detailHeaderActions = (
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
@@ -289,7 +366,7 @@ export function ChatTypeManagementPage() {
<div
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
}`}
}${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`}
>
{detailMode === 'list' ? (
<Card
@@ -373,33 +450,20 @@ export function ChatTypeManagementPage() {
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
.filter((context): context is NonNullable<typeof context> => Boolean(context));
const itemIndex = customChatTypes.findIndex((chatType) => chatType.id === item.id);
const canMoveUp = itemIndex > 0;
const canMoveDown = itemIndex >= 0 && itemIndex < customChatTypes.length - 1;
const itemClassName =
item.id === selectedChatTypeId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item';
return (
<List.Item
className={itemClassName}
onClick={() => {
openDetail(item.id);
}}
actions={[
<Button
key="edit"
type="text"
icon={<EditOutlined />}
disabled={isSaving}
onClick={(event) => {
event.stopPropagation();
openDetail(item.id);
}}
/>,
]}
>
<List.Item className={itemClassName}>
<div className="chat-type-management-page__item-main">
<Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text>
<Tag color="cyan"> {item.sortOrder}</Tag>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
@@ -422,6 +486,39 @@ export function ChatTypeManagementPage() {
</Tag>
))}
</Space>
<div className="chat-type-management-page__item-actions">
<Button
icon={<ArrowUpOutlined />}
disabled={isSaving || !canMoveUp}
onClick={(event) => {
event.stopPropagation();
void moveChatTypeInList(item.id, 'up');
}}
>
</Button>
<Button
icon={<ArrowDownOutlined />}
disabled={isSaving || !canMoveDown}
onClick={(event) => {
event.stopPropagation();
void moveChatTypeInList(item.id, 'down');
}}
>
</Button>
<Button
type="default"
icon={<EditOutlined />}
disabled={isSaving}
onClick={(event) => {
event.stopPropagation();
openDetail(item.id);
}}
>
</Button>
</div>
</div>
</List.Item>
);
@@ -456,9 +553,9 @@ export function ChatTypeManagementPage() {
try {
const savedSnapshot = await setChatTypes(nextChatTypes);
const savedChatType =
savedSnapshot.customChatTypes.find((item) => item.id === values.id || item.name === values.name) ??
savedSnapshot.chatTypes.find((item) => item.id === values.id || item.name === values.name);
const savedChatType = savedSnapshot.chatTypes.find(
(item) => item.id === values.id || item.name === values.name,
);
const nextChatTypeDefaults = savedChatType
? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds)
: chatTypeDefaults;
@@ -480,6 +577,9 @@ export function ChatTypeManagementPage() {
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<Form.Item name="sortOrder" hidden>
<Input />
</Form.Item>
<div className="chat-type-management-page__editor-scroll">
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
@@ -511,9 +611,30 @@ export function ChatTypeManagementPage() {
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className={`chat-type-management-page__default-context-field${isPaneMaximized ? ' chat-type-management-page__default-context-field--hidden' : ''}`}>
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
{ label: '공통연결', value: 'default-contexts' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'default-contexts' | 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : null}
<div
className={`chat-type-management-page__default-context-field${isPaneMaximized ? ' chat-type-management-page__default-context-field--hidden' : ''}${
isMobileViewport && mobileView === 'default-contexts'
? ' chat-type-management-page__default-context-field--mobile-active'
: ''
}`}
>
<div className="chat-type-management-page__default-context-header">
<Text strong> </Text>
<Text strong> </Text>
<Text type="secondary"> .</Text>
</div>
{defaultContexts.filter((context) => context.enabled).length > 0 ? (
@@ -565,26 +686,19 @@ export function ChatTypeManagementPage() {
/>
)}
</div>
<div className="chat-type-management-page__markdown-field">
<div
className={`chat-type-management-page__markdown-field${
isMobileViewport && mobileView !== 'default-contexts'
? ' chat-type-management-page__markdown-field--mobile-active'
: ''
}`}
>
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
{isMobileViewport ? null : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}

0
src/app/main/InitialLoadingOverlay.css Executable file → Normal file
View File

0
src/app/main/InitialLoadingOverlay.tsx Executable file → Normal file
View File

20
src/app/main/MainChatPanel.css Executable file → Normal file
View File

@@ -892,13 +892,25 @@
}
.app-chat-panel__action-group {
gap: 4px;
padding: 3px;
gap: 6px;
padding: 5px;
}
.app-chat-panel__action-group--mobile {
gap: 6px;
padding: 3px;
gap: 8px;
padding: 5px;
}
.app-chat-panel__mobile-actions {
width: 100%;
justify-content: flex-end;
}
.app-chat-panel__action-group--mobile .ant-btn {
width: 44px;
min-width: 44px;
height: 44px;
padding: 0;
}
.app-chat-panel__context-drawer {

File diff suppressed because it is too large Load Diff

155
src/app/main/MainContent.tsx Executable file → Normal file
View File

@@ -5,6 +5,7 @@ import { WindowUI, type WindowFrame } from '../../components/window';
import { BoardPage } from '../../features/board';
import { ComponentSamplesLayout } from '../../features/layout/component-sample-gallery';
import { SampleWidgetsLayout } from '../../features/layout/widget-sample-gallery';
import { renderSavedLayoutContent } from '../../features/layout/renderSavedLayoutContent';
import { HistoryPage } from '../../features/history';
import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../features/planBoard';
import { useSearchLayer } from '../../layer';
@@ -17,7 +18,10 @@ import { ResourceManagementPage } from './ResourceManagementPage';
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
import { MainChatPanel } from './MainChatPanel';
import { PreviewAppOverlay } from './PreviewAppOverlay';
import type { PreviewTargetDescriptor } from './previewRuntime';
import { useMainLayoutContext } from './layout/MainLayoutContext';
import { buildPlayPath } from './routes';
import {
HIDDEN_COMPONENT_IDS,
buildCascadeWindowFrames,
@@ -30,9 +34,32 @@ import type { MainContentProps } from './types';
const { Content } = Layout;
const { Paragraph, Text, Title } = Typography;
function parseWidgetSelectionId(selectionId: string): PreviewTargetDescriptor {
const [targetType, componentId, sampleId] = selectionId.split(':');
if (targetType !== 'widget' || !componentId) {
return null;
}
return {
type: 'widget',
componentId,
sampleId: sampleId || undefined,
};
}
const SAVED_LAYOUT_WINDOW_SELECTION_PREFIX = 'page:play:layout-record:';
function parseSavedLayoutSelectionId(selectionId: string) {
return selectionId.startsWith(SAVED_LAYOUT_WINDOW_SELECTION_PREFIX)
? selectionId.slice(SAVED_LAYOUT_WINDOW_SELECTION_PREFIX.length)
: null;
}
export function MainContent({
contentExpanded,
sidebarOverlayActive = false,
disableWindowLayer = false,
onToggleContentExpanded,
children,
}: MainContentProps) {
@@ -45,6 +72,8 @@ export function MainContent({
widgetSamples,
initialSelectedPlanId,
initialSelectedWorkId,
savedLayouts,
setSavedLayouts,
} = useMainLayoutContext();
const stageRef = useRef<HTMLDivElement>(null);
const [windowFrames, setWindowFrames] = useState<Record<string, WindowFrame>>({});
@@ -60,6 +89,14 @@ export function MainContent({
),
[componentSamples, widgetSamples],
);
const previewAppSelection = useMemo(
() => windowSelections.find((selection) => selection.id === 'page:preview:app') ?? null,
[windowSelections],
);
const regularWindowSelections = useMemo(
() => windowSelections.filter((selection) => selection.id !== 'page:preview:app'),
[windowSelections],
);
const getWindowFrame = (instanceId: string, selectionId: string, index: number): WindowFrame =>
windowFrames[instanceId] ?? getDefaultWindowFrame(selectionId, index);
@@ -130,6 +167,18 @@ export function MainContent({
};
const renderWindowSelectionContent = (selectionId: string, fallbackContent: ReactNode) => {
const savedLayoutId = parseSavedLayoutSelectionId(selectionId);
const savedLayout = savedLayoutId ? savedLayouts.find((item) => item.id === savedLayoutId) ?? null : null;
if (savedLayoutId && savedLayout) {
return renderSavedLayoutContent({
layoutId: savedLayoutId,
layout: savedLayout,
savedLayouts,
onSavedLayoutsChange: setSavedLayouts,
});
}
if (selectionId === 'page:apis:components') {
return (
<ComponentSamplesLayout
@@ -218,7 +267,6 @@ export function MainContent({
if (selectionId === 'page:chat:manage-defaults') {
return <ChatDefaultContextManagementPage />;
}
if (selectionId === 'page:play:layout') {
return <LayoutPlaygroundView />;
}
@@ -252,10 +300,20 @@ export function MainContent({
/>
) : null}
<div className="app-main-layout">{children}</div>
{windowSelections.length > 0 ? (
{!disableWindowLayer && previewAppSelection ? (
<div className="app-main-preview-layer">
<PreviewAppOverlay
pathname={buildPlayPath('cbt')}
onClose={() => {
clearWindowSelection(previewAppSelection.instanceId);
}}
/>
</div>
) : null}
{!disableWindowLayer && regularWindowSelections.length > 0 ? (
<div className="app-main-window-layer">
<div ref={stageRef} className="app-main-window-layer__stage">
{windowSelections.map((windowSelection, index) => {
{regularWindowSelections.map((windowSelection, index) => {
const selectedWindowSample = sampleEntryMap.get(windowSelection.id) ?? null;
const SelectedWindowSample = selectedWindowSample?.Sample ?? null;
const fallbackContent = (
@@ -271,7 +329,8 @@ export function MainContent({
</div>
);
const isWidgetWindow = windowSelection.id.startsWith('widget:');
const widgetPreviewTarget = parseWidgetSelectionId(windowSelection.id);
const isWidgetWindow = widgetPreviewTarget?.type === 'widget';
return (
<WindowUI
@@ -290,15 +349,33 @@ export function MainContent({
onActivate={() => {
activateWindow(windowSelection.instanceId);
}}
onOpenLayoutControls={() => {
setIsWindowLayoutModalOpen(true);
}}
className="app-main-window-layer__window"
onOpenLayoutControls={
isWidgetWindow
? undefined
: () => {
setIsWindowLayoutModalOpen(true);
}
}
className={`app-main-window-layer__window${isWidgetWindow ? ' app-main-window-layer__window--widget-preview' : ''}`}
onClose={() => {
clearWindowSelection(windowSelection.instanceId);
}}
showMaximizeControl={!isWidgetWindow}
minWidth={isWidgetWindow ? 320 : undefined}
minHeight={isWidgetWindow ? 240 : undefined}
compactHeader={isWidgetWindow}
>
{SelectedWindowSample ? (
{isWidgetWindow && widgetPreviewTarget ? (
<div className="app-main-window-layer__sample app-main-window-layer__sample--fill">
<SampleWidgetsLayout
entries={widgetSampleEntries}
includeComponentIds={[widgetPreviewTarget.componentId]}
includeSampleIds={widgetPreviewTarget.sampleId ? [widgetPreviewTarget.sampleId] : []}
disableWidgetCardWrapper
singlePreviewMode
/>
</div>
) : SelectedWindowSample ? (
<div
className={`app-main-window-layer__sample ${
isWidgetWindow
@@ -321,35 +398,37 @@ export function MainContent({
</div>
</div>
) : null}
<Modal
title="윈도우 배치"
open={isWindowLayoutModalOpen}
onCancel={() => {
setIsWindowLayoutModalOpen(false);
}}
footer={null}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Title level={5}> </Title>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
.
</Paragraph>
<Button block onClick={applyCascadeWindowLayout}>
</Button>
</div>
<div>
<Title level={5}> </Title>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
.
</Paragraph>
<Button block onClick={applyGridWindowLayout}>
</Button>
</div>
</Space>
</Modal>
{!disableWindowLayer ? (
<Modal
title="윈도우 배치"
open={isWindowLayoutModalOpen}
onCancel={() => {
setIsWindowLayoutModalOpen(false);
}}
footer={null}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Title level={5}> </Title>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
.
</Paragraph>
<Button block onClick={applyCascadeWindowLayout}>
</Button>
</div>
<div>
<Title level={5}> </Title>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
.
</Paragraph>
<Button block onClick={applyGridWindowLayout}>
</Button>
</div>
</Space>
</Modal>
) : null}
</Content>
);
}

447
src/app/main/MainHeader.tsx Executable file → Normal file
View File

@@ -29,6 +29,7 @@ import {
Space,
Typography,
} from 'antd';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { fetchPlanItems } from '../../features/planBoard/api';
@@ -55,17 +56,13 @@ import {
import { renderModalWithEnterConfirm } from './modalKeyboard';
import {
fetchWebPushConfig,
registerPwaNotificationToken,
registerWebPushSubscription,
unregisterPwaNotificationToken,
unregisterWebPushSubscription,
type WebPushSubscriptionPayload,
} from './notificationApi';
import {
clearNotificationIdentity,
getSavedNotificationDeviceId,
getSavedPwaNotificationToken,
setSavedPwaNotificationToken,
} from './notificationIdentity';
import { resetNonAuthClientState } from './appMaintenance';
import {
@@ -73,6 +70,7 @@ import {
setRegisteredAccessToken,
useTokenAccess,
} from './tokenAccess';
import { isPreviewRuntime } from './previewRuntime';
import { chatConnectionGateway, chatGateway } from './chatV2';
import { HeaderMessageCenter } from './HeaderMessageCenter';
import { fetchChatRuntimeJobDetail } from './mainChatPanel';
@@ -820,13 +818,27 @@ function getServerRestartReservationOverlayState(
}
if (reservation.status === 'executing') {
if (reservation.executionPhase === 'commit-main-worktree') {
return {
title: '재기동 실행 준비 중',
statusText: 'main 작업트리 커밋 단계',
detail: reservation.waitingReason?.trim() || 'main 작업트리 커밋 단계를 확인한 뒤 서버 재기동을 이어갑니다.',
steps: [
{ label: 'main 작업트리 커밋', status: 'active' },
{ label: 'TEST 재기동', status: 'pending' },
{ label: 'WORK 재기동', status: 'pending' },
{ label: '새로고침', status: 'pending' },
],
};
}
if (reservation.target === 'work-server') {
return {
title: 'WORK 서버 재기동 중',
statusText: '새 런타임 반영 중',
detail: reservation.waitingReason?.trim() || 'WORK 서버를 재기동하고 새 런타임을 확인하는 중입니다.',
steps: [
{ label: '재기동 요청', status: 'done' },
{ label: 'main 작업트리 커밋', status: 'done' },
{ label: 'WORK 재기동', status: 'active' },
{ label: '정상 기동 확인', status: 'pending' },
],
@@ -839,7 +851,7 @@ function getServerRestartReservationOverlayState(
statusText: '새 런타임 반영 중',
detail: reservation.waitingReason?.trim() || 'TEST 서버를 재기동하고 새 런타임을 확인하는 중입니다.',
steps: [
{ label: '재기동 요청', status: 'done' },
{ label: 'main 작업트리 커밋', status: 'done' },
{ label: 'TEST 재기동', status: 'active' },
{ label: '정상 기동 확인', status: 'pending' },
],
@@ -863,7 +875,7 @@ function getServerRestartReservationOverlayState(
statusText,
detail,
steps: [
{ label: '예약 확인', status: 'done' },
{ label: 'main 작업트리 커밋', status: 'done' },
{ label: 'TEST 재기동', status: beforeWorkServerRestart ? 'active' : 'done' },
{ label: 'WORK 재기동', status: beforeWorkServerRestart ? 'pending' : 'active' },
{ label: '새로고침', status: 'pending' },
@@ -878,6 +890,7 @@ function getServerRestartReservationOverlayState(
detail: '진행 중인 작업이 모두 끝났습니다. 설정된 대기 시간이 지나면 TEST 서버부터 순서대로 재기동합니다.',
steps: [
{ label: '자동 실행 대기', status: 'active' },
{ label: 'main 작업트리 커밋', status: 'pending' },
{ label: 'TEST 재기동', status: 'pending' },
{ label: 'WORK 재기동', status: 'pending' },
{ label: '새로고침', status: 'pending' },
@@ -904,7 +917,7 @@ function getServerRestartReservationOverlayState(
statusText,
detail,
steps: [
{ label: reservation.target === 'all' ? '예약 확인' : '재기동 요청', status: 'done' },
{ label: reservation.target === 'all' ? 'main 작업트리 커밋' : '재기동 요청', status: 'done' },
{ label: '빌드 실패 감지', status: 'done' },
{ label: 'Codex 자동 개선', status: reservation.autoFix.status === 'completed' ? 'done' : 'active' },
{ label: '재기동 재시도', status: reservation.autoFix.status === 'completed' ? 'active' : 'pending' },
@@ -918,7 +931,7 @@ function getServerRestartReservationOverlayState(
statusText: '정상 기동 최종 확인',
detail: 'TEST/WORK 서버 새 런타임을 다시 확인한 뒤 브라우저 상태를 정리하고 최신 화면으로 연결합니다.',
steps: [
{ label: '예약 확인', status: 'done' },
{ label: 'main 작업트리 커밋', status: 'done' },
{ label: 'TEST 재기동', status: 'done' },
{ label: 'WORK 재기동', status: 'done' },
{ label: '새로고침', status: 'active' },
@@ -1198,6 +1211,32 @@ function hasSecureOrigin() {
return window.isSecureContext || window.location.hostname === 'localhost';
}
function getAppHeaderDomainClassName() {
if (typeof window === 'undefined') {
return 'app-header--prod';
}
const hostname = window.location.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0') {
return 'app-header--local';
}
if (hostname.startsWith('test.')) {
return 'app-header--test';
}
if (hostname.startsWith('rel.')) {
return 'app-header--rel';
}
if (hostname.startsWith('preview.')) {
return 'app-header--preview';
}
return 'app-header--prod';
}
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
@@ -1476,6 +1515,7 @@ export function MainHeader({
}: MainHeaderProps) {
void contentExpanded;
void onToggleContentExpanded;
const appHeaderDomainClassName = getAppHeaderDomainClassName();
const screens = useBreakpoint();
const [modalApi, modalContextHolder] = Modal.useModal();
const navigate = useNavigate();
@@ -1487,16 +1527,10 @@ export function MainHeader({
const [activeAppSettingsCategory, setActiveAppSettingsCategory] = useState<AppSettingsCategoryKey>('automation');
const [activeAppSettingsSection, setActiveAppSettingsSection] = useState<AppSettingsSectionKey>('planDefaults');
const [notificationEnabled, setNotificationEnabled] = useState(false);
const [notificationPendingRegistration, setNotificationPendingRegistration] = useState(false);
const [notificationLoading, setNotificationLoading] = useState(false);
const [notificationFeedback, setNotificationFeedback] = useState<InlineFeedback | null>(null);
const [notificationCopyFeedback, setNotificationCopyFeedback] = useState<InlineFeedback | null>(null);
const [registeredPwaNotificationToken, setRegisteredPwaNotificationToken] = useState(() =>
getSavedPwaNotificationToken(),
);
const [pwaNotificationTokenInput, setPwaNotificationTokenInput] = useState(() => getSavedPwaNotificationToken());
const [pwaNotificationTokenSaving, setPwaNotificationTokenSaving] = useState(false);
const [pwaNotificationTokenFeedback, setPwaNotificationTokenFeedback] = useState<InlineFeedback | null>(null);
const [pwaNotificationTokenCopyFeedback, setPwaNotificationTokenCopyFeedback] = useState<InlineFeedback | null>(null);
const [clientNotificationPermission, setClientNotificationPermission] = useState<ClientNotificationPermissionState>(
() => getClientNotificationPermission(),
);
@@ -1658,27 +1692,53 @@ export function MainHeader({
serverRestartReservationNowTimestamp,
serverRestartReservationReloadPending,
);
const renderTopMenuOptionLabel = (menu: 'docs' | 'plans' | 'play', label: ReactNode, iconLabel: string) => (
<span
aria-label={iconLabel}
onClick={() => {
onChangeTopMenu(menu);
}}
>
{isMobileViewport ? <span aria-label={iconLabel}>{label}</span> : label}
</span>
);
const headerTopMenuOptions = hasAccess
? [
{
label: isMobileViewport ? <span aria-label="Docs"><FileMarkdownOutlined /></span> : 'Docs',
label: renderTopMenuOptionLabel(
'docs',
isMobileViewport ? <FileMarkdownOutlined /> : 'Docs',
'Docs',
),
value: 'docs',
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
},
{
label: isMobileViewport ? <span aria-label="작업"><ProfileOutlined /></span> : '작업',
label: renderTopMenuOptionLabel(
'plans',
isMobileViewport ? <ProfileOutlined /> : '작업',
'작업',
),
value: 'plans',
icon: isMobileViewport ? undefined : <ProfileOutlined />,
},
{
label: isMobileViewport ? <span aria-label="Play"><ApiOutlined /></span> : 'Play',
label: renderTopMenuOptionLabel(
'play',
isMobileViewport ? <ApiOutlined /> : 'Play',
'Play',
),
value: 'play',
icon: isMobileViewport ? undefined : <ApiOutlined />,
},
]
: [
{
label: isMobileViewport ? <span aria-label="Docs"><FileMarkdownOutlined /></span> : 'Docs',
label: renderTopMenuOptionLabel(
'docs',
isMobileViewport ? <FileMarkdownOutlined /> : 'Docs',
'Docs',
),
value: 'docs',
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
},
@@ -1783,6 +1843,46 @@ export function MainHeader({
appConfigDraft.gestureShortcuts,
);
const activeAppSettingsSectionOptions = getAppSettingsSectionOptions(activeAppSettingsCategory);
const ensureWebPushSubscriptionRegistered = async (registration: ServiceWorkerRegistration) => {
const config = await fetchWebPushConfig();
setWebPushConfigured(Boolean(config.enabled && config.publicKey));
if (!config.enabled || !config.publicKey) {
throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.');
}
let subscription = await registration.pushManager.getSubscription();
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
if (
subscription &&
!isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey)
) {
await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined);
await subscription.unsubscribe().catch(() => undefined);
subscription = null;
}
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: expectedApplicationServerKey,
});
}
await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId());
};
const clearWebPushSubscriptionRegistration = async (registration: ServiceWorkerRegistration) => {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await unregisterWebPushSubscription(subscription.endpoint);
await subscription.unsubscribe();
}
};
const syncRegisteredWebPushStatus = async () => {
const permission = getClientNotificationPermission();
setClientNotificationPermission(permission);
@@ -1790,6 +1890,7 @@ export function MainHeader({
if (permission === 'unsupported') {
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
return;
}
@@ -1799,11 +1900,13 @@ export function MainHeader({
if (!config.enabled || !config.publicKey) {
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
return;
}
if (Notification.permission !== 'granted') {
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
return;
}
@@ -1811,33 +1914,25 @@ export function MainHeader({
if (!registration) {
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
return;
}
let subscription = await registration.pushManager.getSubscription();
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
return;
}
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
if (!isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey)) {
await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined);
await subscription.unsubscribe().catch(() => undefined);
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: expectedApplicationServerKey,
});
}
await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId());
await ensureWebPushSubscriptionRegistered(registration);
setNotificationEnabled(true);
setNotificationPendingRegistration(false);
setNotificationFeedback(null);
} catch {
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
}
};
@@ -1845,6 +1940,68 @@ export function MainHeader({
void syncRegisteredWebPushStatus();
}, []);
useEffect(() => {
if (!notificationPendingRegistration) {
return;
}
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
return;
}
let cancelled = false;
const finalizePendingRegistration = async () => {
try {
await navigator.serviceWorker.ready;
if (cancelled) {
return;
}
setNotificationLoading(true);
const registration = await getPushServiceWorkerRegistration();
if (!registration) {
throw new Error('알림 서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.');
}
await ensureWebPushSubscriptionRegistered(registration);
if (cancelled) {
return;
}
setNotificationEnabled(true);
setNotificationPendingRegistration(false);
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 서버에 등록했습니다.' });
} catch (error) {
if (cancelled) {
return;
}
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
setNotificationFeedback({
tone: 'error',
message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.',
});
} finally {
if (!cancelled) {
setNotificationLoading(false);
}
}
};
void finalizePendingRegistration();
return () => {
cancelled = true;
};
}, [notificationPendingRegistration]);
useEffect(() => {
const handleSync = () => {
void syncRegisteredWebPushStatus();
@@ -2151,9 +2308,21 @@ export function MainHeader({
return;
}
if (isPreviewRuntime()) {
setNotificationEnabled(false);
setNotificationCopyFeedback(null);
setNotificationLoading(false);
setNotificationFeedback({
tone: 'warning',
message: '미리보기 런타임에서는 알림 등록을 지원하지 않습니다. 실제 앱 화면에서 다시 시도해 주세요.',
});
return;
}
const previousEnabled = notificationEnabled;
setNotificationCopyFeedback(null);
setNotificationLoading(true);
setNotificationPendingRegistration(false);
setNotificationEnabled(nextEnabled);
if (nextEnabled) {
@@ -2177,108 +2346,37 @@ export function MainHeader({
if (!registration) {
if (nextEnabled) {
if (import.meta.env.DEV) {
const serviceWorkerUrl = '/dev-sw.js?dev-sw';
let swFetchStatus = 'unknown';
try {
const response = await fetch(serviceWorkerUrl, { cache: 'no-store' });
swFetchStatus = `${response.status} ${response.statusText}`;
} catch {
swFetchStatus = 'fetch failed';
}
let registrationSummary = 'none';
try {
const registrations = await navigator.serviceWorker.getRegistrations();
registrationSummary =
registrations.length === 0
? 'none'
: registrations
.map((item) => {
const states = [
item.active ? `active:${item.active.state}` : 'active:none',
item.waiting ? `waiting:${item.waiting.state}` : 'waiting:none',
item.installing ? `installing:${item.installing.state}` : 'installing:none',
];
return `${item.scope} [${states.join(', ')}]`;
})
.join(' | ');
} catch {
registrationSummary = 'read failed';
}
throw new Error(
[
'알림 서비스워커 준비가 아직 끝나지 않았습니다.',
`dev-sw 응답: ${swFetchStatus}`,
`등록 상태: ${registrationSummary}`,
`등록 시도: ${
lastPushSwRegisterAttempts.length === 0
? 'none'
: lastPushSwRegisterAttempts
.map((attempt) => `${attempt.mode} ${attempt.url} -> ${attempt.error}`)
.join(' | ')
}`,
`secureContext: ${window.isSecureContext ? 'yes' : 'no'}`,
`permission: ${Notification.permission}`,
].join(' '),
);
}
throw new Error('알림 서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.');
setNotificationEnabled(true);
setNotificationPendingRegistration(true);
setNotificationFeedback({
tone: 'info',
message: '알림 서비스워커를 준비 중입니다. 준비가 끝나는 대로 자동으로 등록합니다.',
});
return;
}
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' });
return;
}
if (nextEnabled) {
const config = await fetchWebPushConfig();
setWebPushConfigured(Boolean(config.enabled && config.publicKey));
if (!config.enabled || !config.publicKey) {
throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.');
}
let subscription = await registration.pushManager.getSubscription();
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
if (
subscription &&
!isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey)
) {
await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined);
await subscription.unsubscribe().catch(() => undefined);
subscription = null;
}
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: expectedApplicationServerKey,
});
}
await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId());
await ensureWebPushSubscriptionRegistered(registration);
setNotificationEnabled(true);
setNotificationPendingRegistration(false);
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 서버에 등록했습니다.' });
return;
}
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await unregisterWebPushSubscription(subscription.endpoint);
await subscription.unsubscribe();
}
await clearWebPushSubscriptionRegistration(registration);
setNotificationEnabled(false);
setNotificationPendingRegistration(false);
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' });
} catch (error) {
setNotificationEnabled(previousEnabled);
setNotificationPendingRegistration(false);
setNotificationFeedback({
tone: 'error',
message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.',
@@ -2288,69 +2386,6 @@ export function MainHeader({
}
};
const handleRegisterPwaNotificationToken = async () => {
const trimmedToken = pwaNotificationTokenInput.trim();
setPwaNotificationTokenCopyFeedback(null);
if (!trimmedToken) {
setPwaNotificationTokenFeedback({ tone: 'warning', message: '등록할 PWA 알림 토큰을 입력해 주세요.' });
return;
}
setPwaNotificationTokenSaving(true);
try {
await registerPwaNotificationToken({
token: trimmedToken,
deviceId: getSavedNotificationDeviceId(),
});
if (registeredPwaNotificationToken && registeredPwaNotificationToken !== trimmedToken) {
await unregisterPwaNotificationToken(registeredPwaNotificationToken);
}
setSavedPwaNotificationToken(trimmedToken);
setRegisteredPwaNotificationToken(trimmedToken);
void syncAppConfigFromServer();
setPwaNotificationTokenFeedback({ tone: 'success', message: 'PWA 알림 토큰을 서버에 등록했습니다.' });
} catch (error) {
setPwaNotificationTokenFeedback({
tone: 'error',
message: error instanceof Error ? error.message : 'PWA 알림 토큰 등록에 실패했습니다.',
});
} finally {
setPwaNotificationTokenSaving(false);
}
};
const handleClearPwaNotificationToken = async () => {
const tokenToRemove = registeredPwaNotificationToken || pwaNotificationTokenInput.trim();
setPwaNotificationTokenCopyFeedback(null);
if (!tokenToRemove) {
setPwaNotificationTokenFeedback({ tone: 'info', message: '제거할 PWA 알림 토큰이 없습니다.' });
return;
}
setPwaNotificationTokenSaving(true);
try {
await unregisterPwaNotificationToken(tokenToRemove);
setSavedPwaNotificationToken('');
setRegisteredPwaNotificationToken('');
setPwaNotificationTokenInput('');
void syncAppConfigFromServer();
setPwaNotificationTokenFeedback({ tone: 'success', message: 'PWA 알림 토큰을 제거했습니다.' });
} catch (error) {
setPwaNotificationTokenFeedback({
tone: 'error',
message: error instanceof Error ? error.message : 'PWA 알림 토큰 제거에 실패했습니다.',
});
} finally {
setPwaNotificationTokenSaving(false);
}
};
const refreshServerStatuses = async () => {
const items = await fetchServerCommands();
const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null;
@@ -4421,7 +4456,7 @@ export function MainHeader({
</div>
</div>
) : null}
<Header className="app-header">
<Header className={`app-header ${appHeaderDomainClassName}`}>
<Space size={12} className="app-header__row">
<Space size={12} className="app-header__menu-side">
<Button
@@ -4446,7 +4481,7 @@ export function MainHeader({
type="button"
className={`${connectionIndicatorClassName} app-header__connection-indicator--labelled`}
aria-label={chatConnectionLabel}
title={chatConnectionLabel}
title={isMobileViewport ? undefined : chatConnectionLabel}
onClick={() => {
setIsRuntimeModalOpen(true);
}}
@@ -4465,7 +4500,7 @@ export function MainHeader({
<span
className={connectionCountBadgeClassName}
aria-label={`실행 중 요청 ${runningRuntimeCount}`}
title={`실행 중 요청 ${runningRuntimeCount}`}
title={isMobileViewport ? undefined : `실행 중 요청 ${runningRuntimeCount}`}
>
{runningRuntimeBadgeLabel}
</span>
@@ -4751,58 +4786,22 @@ export function MainHeader({
{clientNotificationPermission === 'unsupported' ? (
<Text type="warning"> Web Push .</Text>
) : null}
{isPreviewRuntime() ? (
<Text type="warning"> .</Text>
) : null}
{!hasSecureOrigin() ? (
<Text type="warning"> HTTPS localhost .</Text>
) : null}
{renderFeedback(notificationFeedback, notificationCopyFeedback, setNotificationCopyFeedback)}
<Checkbox
checked={notificationEnabled}
disabled={notificationLoading}
disabled={notificationLoading || isPreviewRuntime()}
onChange={(event) => {
void syncNotificationEnabled(event.target.checked);
}}
>
</Checkbox>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong>PWA </Text>
<Text type="secondary">
PWA에서 .
</Text>
<Text type="secondary">PWA : {registeredPwaNotificationToken ? '등록됨' : '미등록'}</Text>
<Input.TextArea
value={pwaNotificationTokenInput}
placeholder="PWA에서 받은 알림 토큰 입력"
autoSize={{ minRows: 2, maxRows: 4 }}
onChange={(event) => {
setPwaNotificationTokenInput(event.target.value);
}}
/>
{renderFeedback(
pwaNotificationTokenFeedback,
pwaNotificationTokenCopyFeedback,
setPwaNotificationTokenCopyFeedback,
)}
<Space wrap>
<Button
type="primary"
loading={pwaNotificationTokenSaving}
onClick={() => {
void handleRegisterPwaNotificationToken();
}}
>
PWA
</Button>
<Button
disabled={pwaNotificationTokenSaving || !registeredPwaNotificationToken}
onClick={() => {
void handleClearPwaNotificationToken();
}}
>
PWA
</Button>
</Space>
</Space>
</Space>
) : null}
{activeSettingsModal === 'token' ? (

426
src/app/main/MainLayout.css Executable file → Normal file
View File

@@ -21,20 +21,59 @@
linear-gradient(180deg, #f8fbff 0%, #eff5ff 45%, #ffffff 100%);
}
.app-shell--preview-runtime {
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
}
.app-header {
--app-header-bg: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(248, 250, 252, 0.86) 100%);
--app-header-border: rgba(148, 163, 184, 0.16);
--app-header-shadow: rgba(148, 163, 184, 0.08);
--app-header-base-height: 60px;
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
height: 60px;
padding: 0 18px;
background: rgba(255, 255, 255, 0.82);
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
min-height: calc(var(--app-header-base-height) + env(safe-area-inset-top, 0px));
padding: env(safe-area-inset-top, 0px) 18px 0;
background: var(--app-header-bg);
border-bottom: 1px solid var(--app-header-border);
box-shadow: inset 0 -1px 0 var(--app-header-shadow);
backdrop-filter: blur(18px);
}
.app-header.app-header--test {
--app-header-bg: linear-gradient(135deg, rgba(236, 253, 245, 0.94) 0%, rgba(220, 252, 231, 0.9) 100%);
--app-header-border: rgba(74, 222, 128, 0.28);
--app-header-shadow: rgba(34, 197, 94, 0.14);
}
.app-header.app-header--rel {
--app-header-bg: linear-gradient(135deg, rgba(255, 247, 237, 0.95) 0%, rgba(254, 215, 170, 0.78) 100%);
--app-header-border: rgba(251, 146, 60, 0.28);
--app-header-shadow: rgba(249, 115, 22, 0.12);
}
.app-header.app-header--preview {
--app-header-bg: linear-gradient(135deg, rgba(239, 246, 255, 0.95) 0%, rgba(219, 234, 254, 0.9) 100%);
--app-header-border: rgba(96, 165, 250, 0.28);
--app-header-shadow: rgba(59, 130, 246, 0.12);
}
.app-header.app-header--prod {
--app-header-bg: linear-gradient(135deg, rgba(250, 245, 255, 0.95) 0%, rgba(243, 232, 255, 0.88) 100%);
--app-header-border: rgba(168, 85, 247, 0.24);
--app-header-shadow: rgba(147, 51, 234, 0.12);
}
.app-header.app-header--local {
--app-header-bg: linear-gradient(135deg, rgba(241, 245, 249, 0.96) 0%, rgba(226, 232, 240, 0.92) 100%);
--app-header-border: rgba(100, 116, 139, 0.24);
--app-header-shadow: rgba(71, 85, 105, 0.12);
}
.app-header__row {
display: flex;
align-items: center;
@@ -638,6 +677,13 @@
width: 100%;
}
.app-main-panel--widget-preview {
flex: 1 1 auto;
height: 100%;
min-height: 100%;
overflow: hidden;
}
.app-main-layout:has(.app-main-panel--play-saved) {
padding: 0;
gap: 0;
@@ -756,11 +802,20 @@
}
.app-main-panel:has(.resource-management-page) {
display: flex;
flex-direction: column;
flex: 1 1 auto;
height: 100%;
min-height: 100%;
overflow: hidden;
}
.app-main-panel:has(.resource-management-page) > .resource-management-page {
flex: 1 1 0;
min-height: 0;
height: 100%;
}
.app-main-layout:has(.resource-management-page) {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
@@ -871,25 +926,33 @@
}
.app-shell:has(.resource-management-page),
.app-shell:has(.resource-management-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.resource-management-page),
.app-main-panel:has(.resource-management-page),
.app-main-layout:has(.resource-management-page) {
.app-shell:has(.resource-management-page) > .ant-layout {
width: 100%;
min-width: 100%;
max-width: 100%;
}
.app-shell:has(.resource-management-page),
.app-shell:has(.resource-management-page) > .ant-layout,
.app-shell:has(.resource-management-page) > .ant-layout {
height: var(--app-viewport-height);
min-height: var(--app-viewport-height);
overflow: hidden;
}
.app-main-content.ant-layout-content:has(.resource-management-page),
.app-main-panel:has(.resource-management-page),
.app-main-layout:has(.resource-management-page) {
height: calc(var(--app-viewport-height) - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
max-height: calc(var(--app-viewport-height) - 52px);
overflow: hidden;
}
.app-main-layout:has(.resource-management-page) {
gap: 0;
padding: 0;
}
.app-shell:has(.chat-type-management-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.chat-type-management-page),
.app-main-panel:has(.chat-type-management-page),
@@ -1053,6 +1116,29 @@
padding: 12px 20px 20px;
}
.app-main-card--widget-preview.ant-card {
flex: 1 1 auto;
height: 100%;
border-radius: 0;
}
.app-main-card--widget-preview.ant-card .ant-card-head {
min-height: 34px;
padding: 4px 10px 0;
}
.app-main-card--widget-preview.ant-card .ant-card-head-title {
padding: 4px 0;
font-size: 13px;
}
.app-main-card--widget-preview.ant-card .ant-card-body {
flex: 1 1 auto;
min-height: 0;
padding: 0;
overflow: hidden;
}
.app-main-copy.ant-typography {
margin-bottom: 20px;
}
@@ -1064,6 +1150,13 @@
pointer-events: none;
}
.app-main-preview-layer {
position: static;
inset: 0;
z-index: 30;
overflow: visible;
}
.app-main-window-layer__stage {
position: relative;
width: 100%;
@@ -1077,6 +1170,10 @@
pointer-events: auto;
}
.app-main-window-layer__window--widget-preview {
border-radius: 0;
}
.app-main-window-layer__body {
display: flex;
flex: 1 1 auto;
@@ -1143,6 +1240,309 @@
gap: 8px;
}
.preview-app-window {
display: flex;
flex: 1 1 auto;
align-items: stretch;
justify-content: stretch;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 0;
background: #ffffff;
overscroll-behavior: contain;
}
.preview-app-window__viewport {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.preview-app-window__viewport--desktop {
border-radius: 0;
box-shadow: none;
}
.preview-app-window__viewport--mobile {
position: relative;
width: 100%;
height: 100%;
border-radius: 0;
border: 0;
background: #ffffff;
box-shadow: none;
}
.preview-app-window__frame {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
border: 0;
display: block;
background: #fff;
}
body.preview-app-overlay-open {
overflow: hidden;
}
.preview-app-overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 0;
min-height: 0;
overflow: hidden;
background:
linear-gradient(180deg, rgba(244, 247, 251, 0.98) 0%, rgba(231, 238, 248, 0.96) 100%);
z-index: 2000;
overscroll-behavior: none;
}
.preview-app-overlay--minimized {
inset: auto;
width: 168px;
height: 44px;
border-radius: 999px;
overflow: visible;
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
touch-action: none;
}
.preview-app-overlay--mobile-shell {
inset: auto;
width: min(430px, calc(100vw - 24px));
height: min(860px, calc(100vh - 24px));
border-radius: 40px;
overflow: visible;
background: transparent;
touch-action: none;
}
.preview-app-overlay__header {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 44px;
min-height: 44px;
padding: 0 max(10px, env(safe-area-inset-right, 0px)) 0 max(12px, env(safe-area-inset-left, 0px));
background:
linear-gradient(135deg, rgba(231, 242, 255, 0.98) 0%, rgba(255, 244, 230, 0.98) 52%, rgba(255, 236, 214, 0.98) 100%);
color: #172554;
border-bottom: 1px solid rgba(96, 165, 250, 0.2);
box-shadow:
0 10px 28px rgba(37, 99, 235, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.78);
user-select: none;
width: 100%;
}
.preview-app-overlay--mobile-shell .preview-app-overlay__header {
border: 1px solid rgba(148, 163, 184, 0.18);
border-bottom: 0;
border-radius: 40px 40px 0 0;
box-shadow:
0 18px 42px rgba(15, 23, 42, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.82);
cursor: grab;
}
.preview-app-overlay--mobile-shell .preview-app-overlay__header:active {
cursor: grabbing;
}
.preview-app-overlay--minimized .preview-app-overlay__header {
border: 1px solid rgba(96, 165, 250, 0.26);
border-radius: 999px;
background:
linear-gradient(135deg, rgba(222, 239, 255, 0.98) 0%, rgba(255, 248, 238, 0.98) 46%, rgba(255, 233, 211, 0.98) 100%);
box-shadow:
0 20px 40px rgba(15, 23, 42, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.82);
cursor: grab;
}
.preview-app-overlay--minimized .preview-app-overlay__header:active {
cursor: grabbing;
}
.preview-app-overlay__title {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1 1 auto;
}
.preview-app-overlay__title--minimized {
flex: 0 1 auto;
}
.preview-app-overlay__title-badge {
width: 12px;
height: 12px;
flex: 0 0 12px;
border-radius: 999px;
background:
radial-gradient(circle at 32% 32%, #ffffff 0%, #bfdbfe 24%, #60a5fa 58%, #1d4ed8 100%);
box-shadow:
0 0 0 4px rgba(191, 219, 254, 0.42),
0 4px 10px rgba(37, 99, 235, 0.2);
}
.preview-app-overlay__title-copy {
display: inline-flex;
flex-direction: column;
min-width: 0;
line-height: 1.05;
}
.preview-app-overlay__title-copy strong {
color: #172554;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.01em;
}
.preview-app-overlay__title-copy span {
overflow: hidden;
color: #475569;
font-size: 11px;
white-space: nowrap;
text-overflow: ellipsis;
}
.preview-app-overlay__actions {
display: flex;
align-items: center;
gap: 2px;
flex: 0 0 auto;
margin-left: auto;
position: relative;
z-index: 1;
}
.preview-app-overlay__actions .ant-btn {
width: 32px;
min-width: 32px;
height: 32px;
color: inherit;
border-radius: 999px;
}
.preview-app-overlay__actions .ant-btn:hover {
background: rgba(226, 232, 240, 0.68);
}
.preview-app-overlay__minimized-content {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
padding-left: 8px;
color: #0f172a;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.01em;
}
.preview-app-overlay__minimized-dot {
width: 10px;
height: 10px;
flex: 0 0 10px;
border-radius: 999px;
background:
radial-gradient(circle at 35% 35%, #fef3c7 0%, #f59e0b 45%, #ea580c 100%);
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.16);
}
.preview-app-overlay__minimized-label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.preview-app-overlay__body {
position: relative;
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #ffffff;
}
.preview-app-overlay--mobile-shell .preview-app-overlay__body {
height: calc(100% - 44px);
border: 1px solid rgba(148, 163, 184, 0.18);
border-top: 0;
border-radius: 0 0 40px 40px;
box-shadow:
0 28px 64px rgba(15, 23, 42, 0.18),
0 0 0 1px rgba(255, 255, 255, 0.52);
}
.preview-app-overlay--mobile-shell .preview-app-window {
padding: 0;
background: #ffffff;
}
.preview-app-overlay--mobile-shell .preview-app-window__viewport--mobile {
width: 100%;
height: 100%;
border-radius: 0 0 40px 40px;
overflow: hidden;
}
@media (max-width: 900px) {
.preview-app-window__viewport--desktop {
border-radius: 0;
}
.preview-app-window__viewport--mobile {
border-radius: 0;
}
}
@media (max-width: 768px) {
.preview-app-overlay--mobile-shell {
inset: 0;
width: 100%;
height: 100%;
border-radius: 0;
overflow: hidden;
background:
linear-gradient(180deg, rgba(244, 247, 251, 0.98) 0%, rgba(231, 238, 248, 0.96) 100%);
}
.preview-app-overlay--mobile-shell .preview-app-overlay__header,
.preview-app-overlay--mobile-shell .preview-app-overlay__body,
.preview-app-overlay--mobile-shell .preview-app-window__viewport--mobile {
border-radius: 0;
}
.preview-app-overlay--mobile-shell .preview-app-overlay__header,
.preview-app-overlay--mobile-shell .preview-app-overlay__body {
border: 0;
box-shadow: none;
cursor: default;
}
}
.preview-app-overlay__body--hidden {
visibility: hidden;
pointer-events: none;
}
.app-main-stack {
width: 100%;
min-width: 0;
@@ -1172,8 +1572,8 @@
@media (max-width: 768px) {
.app-header {
padding: 6px 10px;
height: 52px;
--app-header-base-height: 52px;
padding: calc(env(safe-area-inset-top, 0px) + 6px) 10px 6px;
}
.app-header__row {
@@ -1273,6 +1673,10 @@
inset: 8px;
}
.app-main-preview-layer {
inset: 0;
}
.app-main-window-layer__stage {
min-height: calc(var(--app-viewport-height) - 68px);
border-radius: 18px;

0
src/app/main/MainSidebar.tsx Executable file → Normal file
View File

0
src/app/main/MainView.tsx Executable file → Normal file
View File

165
src/app/main/ManagementPage.shared.css Executable file → Normal file
View File

@@ -117,7 +117,7 @@
}
.chat-type-management-page__item {
cursor: pointer;
cursor: default;
border: 1px solid #f0f0f0;
border-radius: 12px;
margin-bottom: 8px;
@@ -145,9 +145,17 @@
margin-bottom: 0;
}
.chat-type-management-page__item-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.chat-type-management-page__default-context-field {
display: flex;
flex-direction: column;
flex: 0 0 auto;
gap: 8px;
padding: 10px 12px;
border-radius: 14px;
@@ -168,6 +176,9 @@
.chat-type-management-page__default-context-options {
width: 100%;
max-height: min(30dvh, 272px);
overflow: auto;
padding-right: 2px;
}
.chat-type-management-page__default-context-space {
@@ -184,6 +195,19 @@
border: 1px solid #e5e7eb;
}
.chat-type-management-page__default-context-option-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.chat-type-management-page__default-context-link.ant-btn {
padding-inline: 0;
height: auto;
white-space: nowrap;
}
.chat-type-management-page__default-context-option-copy {
padding-left: 24px;
}
@@ -467,11 +491,24 @@
.chat-type-management-page__editor-scroll {
gap: 3px;
padding: 0 0 6px;
overflow: hidden;
padding: 0 0 calc(6px + env(safe-area-inset-bottom, 0px));
}
.chat-type-management-page__mobile-toggle {
display: inline-flex;
display: flex;
width: 100%;
flex: 0 0 auto;
}
.chat-type-management-page__mobile-toggle.ant-segmented {
width: 100%;
}
.chat-type-management-page__mobile-toggle .ant-segmented-group {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.chat-type-management-page__editor-toolbar {
@@ -486,10 +523,81 @@
align-items: start;
}
.chat-type-management-page__meta-grid {
order: 1;
}
.chat-type-management-page__default-context-field {
order: 2;
display: none;
}
.chat-type-management-page__markdown-editor {
gap: 0;
}
.chat-type-management-page__markdown-field {
order: 3;
gap: 0;
display: none;
}
.chat-type-management-page__default-context-field--mobile-active,
.chat-type-management-page__markdown-field--mobile-active {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.chat-type-management-page__default-context-header {
flex-direction: column;
}
.chat-type-management-page__default-context-options {
flex: 1 1 auto;
max-height: none;
overflow: auto;
padding-right: 2px;
}
.chat-type-management-page__default-context-space {
display: flex;
flex-direction: column;
}
.chat-type-management-page__default-context-preview {
flex: 0 0 auto;
}
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-field,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-editor,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-grid,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea textarea,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-field,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-editor,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-grid,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-pane,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview-body {
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-field,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-editor,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-grid,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-field,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-editor,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-grid,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-pane,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview {
overflow: hidden;
}
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
justify-content: flex-end;
}
@@ -589,6 +697,52 @@
min-height: clamp(320px, calc(100dvh - 430px), 520px) !important;
}
.chat-type-management-page--mobile-view-default-contexts .chat-type-management-page__default-context-field,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-field,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-editor,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-grid,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane .ant-form-item,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane .ant-form-item-control,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane .ant-form-item-control-input,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane .ant-form-item-control-input-content,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea textarea,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-field,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-editor,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-grid,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-pane,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview-body {
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
}
.chat-type-management-page--mobile-view-default-contexts .chat-type-management-page__default-context-field,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-field,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-editor,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-grid,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-field,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-editor,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-grid,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-pane,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview {
overflow: hidden;
}
.chat-type-management-page--mobile-view-default-contexts .chat-type-management-page__default-context-options,
.chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview-body,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea textarea {
overflow: auto !important;
}
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea,
.chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea textarea {
height: 100% !important;
}
.chat-type-management-page--pane-maximized {
height: calc(100dvh - 52px);
max-height: calc(100dvh - 52px);
@@ -665,4 +819,9 @@
min-width: 34px;
height: 34px;
}
.chat-type-management-page__item-actions .ant-btn {
flex: 1 1 calc(50% - 4px);
min-width: 0;
}
}

View File

@@ -0,0 +1,437 @@
import { CloseOutlined, DesktopOutlined, MinusOutlined, MobileOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import {
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { createPortal } from 'react-dom';
import { PreviewAppWindow } from './PreviewAppWindow';
import type { PreviewTargetDescriptor } from './previewRuntime';
type PreviewAppOverlayProps = {
pathname: string;
search?: string;
targetDescriptor?: PreviewTargetDescriptor;
onClose: () => void;
};
type DragPosition = {
x: number;
y: number;
};
const HEADER_HEIGHT = 44;
const MINIMIZED_WIDTH = 168;
const MOBILE_SHELL_WIDTH = 430;
const MOBILE_SHELL_HEIGHT = 860;
const VIEWPORT_PADDING = 12;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getDefaultMinimizedPosition(): DragPosition {
if (typeof window === 'undefined') {
return {
x: VIEWPORT_PADDING,
y: VIEWPORT_PADDING,
};
}
return {
x: Math.max(VIEWPORT_PADDING, window.innerWidth - MINIMIZED_WIDTH - VIEWPORT_PADDING),
y: Math.max(VIEWPORT_PADDING, window.innerHeight - HEADER_HEIGHT - VIEWPORT_PADDING),
};
}
function getMobileShellMetrics() {
if (typeof window === 'undefined') {
return {
width: MOBILE_SHELL_WIDTH,
height: MOBILE_SHELL_HEIGHT,
};
}
return {
width: Math.min(MOBILE_SHELL_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)),
height: Math.min(MOBILE_SHELL_HEIGHT, Math.max(520, window.innerHeight - VIEWPORT_PADDING * 2)),
};
}
function getDefaultMobileShellPosition(): DragPosition {
if (typeof window === 'undefined') {
return {
x: VIEWPORT_PADDING,
y: VIEWPORT_PADDING,
};
}
const { width, height } = getMobileShellMetrics();
return {
x: Math.max(VIEWPORT_PADDING, (window.innerWidth - width) / 2),
y: Math.max(VIEWPORT_PADDING, (window.innerHeight - height) / 2),
};
}
export function PreviewAppOverlay({
pathname,
search = '',
targetDescriptor = null,
onClose,
}: PreviewAppOverlayProps) {
const minimizedPositionRef = useRef<DragPosition>(getDefaultMinimizedPosition());
const mobileShellPositionRef = useRef<DragPosition>(getDefaultMobileShellPosition());
const rootRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef<{
pointerId: number;
lastX: number;
lastY: number;
captureTarget: HTMLDivElement;
} | null>(null);
const dragMovedRef = useRef(false);
const [minimized, setMinimized] = useState(false);
const [deviceMode, setDeviceMode] = useState<'desktop' | 'mobile'>('mobile');
const [isMobileViewport, setIsMobileViewport] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false,
);
const [position, setPosition] = useState<DragPosition>(() => minimizedPositionRef.current);
const isDesktopMobileShell = !minimized && deviceMode === 'mobile' && !isMobileViewport;
useEffect(() => {
document.body.classList.add('preview-app-overlay-open');
return () => {
document.body.classList.remove('preview-app-overlay-open');
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 768px)');
const handleChange = (event: MediaQueryListEvent) => {
setIsMobileViewport(event.matches);
};
setIsMobileViewport(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, []);
useEffect(() => {
if (!minimized && !isDesktopMobileShell) {
return;
}
const resizeListener = () => {
if (minimized) {
setPosition((current) => {
const nextPosition = {
x: clamp(
current.x,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerWidth - MINIMIZED_WIDTH - VIEWPORT_PADDING),
),
y: clamp(
current.y,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerHeight - HEADER_HEIGHT - VIEWPORT_PADDING),
),
};
minimizedPositionRef.current = nextPosition;
return nextPosition;
});
return;
}
const { width, height } = getMobileShellMetrics();
setPosition((current) => ({
x: clamp(
current.x,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
),
y: clamp(
current.y,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING),
),
}));
};
window.addEventListener('resize', resizeListener);
resizeListener();
return () => {
window.removeEventListener('resize', resizeListener);
};
}, [isDesktopMobileShell, minimized]);
useEffect(() => {
if (minimized) {
minimizedPositionRef.current = position;
}
}, [minimized, position]);
useEffect(() => {
if (isDesktopMobileShell) {
mobileShellPositionRef.current = position;
}
}, [isDesktopMobileShell, position]);
useEffect(() => {
if (!minimized && !isDesktopMobileShell) {
dragStateRef.current = null;
dragMovedRef.current = false;
return;
}
const handlePointerMove = (event: PointerEvent) => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
const deltaX = event.clientX - dragState.lastX;
const deltaY = event.clientY - dragState.lastY;
if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {
dragMovedRef.current = true;
}
dragState.lastX = event.clientX;
dragState.lastY = event.clientY;
const maxX = minimized
? Math.max(VIEWPORT_PADDING, window.innerWidth - MINIMIZED_WIDTH - VIEWPORT_PADDING)
: Math.max(
VIEWPORT_PADDING,
window.innerWidth - getMobileShellMetrics().width - VIEWPORT_PADDING,
);
const maxY = minimized
? Math.max(VIEWPORT_PADDING, window.innerHeight - HEADER_HEIGHT - VIEWPORT_PADDING)
: Math.max(
VIEWPORT_PADDING,
window.innerHeight - getMobileShellMetrics().height - VIEWPORT_PADDING,
);
setPosition((current) => {
const nextPosition = {
x: clamp(current.x + deltaX, VIEWPORT_PADDING, maxX),
y: clamp(current.y + deltaY, VIEWPORT_PADDING, maxY),
};
if (minimized) {
minimizedPositionRef.current = nextPosition;
}
return nextPosition;
});
};
const finishPointerDrag = (event: PointerEvent) => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
if (dragState.captureTarget.hasPointerCapture(event.pointerId)) {
dragState.captureTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', finishPointerDrag);
window.addEventListener('pointercancel', finishPointerDrag);
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', finishPointerDrag);
window.removeEventListener('pointercancel', finishPointerDrag);
};
}, [isDesktopMobileShell, minimized]);
useEffect(() => {
if (isDesktopMobileShell) {
const { width, height } = getMobileShellMetrics();
const nextPosition = {
x: clamp(
mobileShellPositionRef.current.x,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
),
y: clamp(
mobileShellPositionRef.current.y,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING),
),
};
mobileShellPositionRef.current = nextPosition;
setPosition(nextPosition);
}
}, [isDesktopMobileShell]);
const handleHeaderPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!minimized && !isDesktopMobileShell) {
return;
}
const rootRect = rootRef.current?.getBoundingClientRect();
if (!rootRect) {
return;
}
dragStateRef.current = {
pointerId: event.pointerId,
lastX: event.clientX,
lastY: event.clientY,
captureTarget: event.currentTarget,
};
dragMovedRef.current = false;
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
};
const handleHeaderPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (dragState?.pointerId === event.pointerId) {
if (dragState.captureTarget.hasPointerCapture(event.pointerId)) {
dragState.captureTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
}
};
const handleMinimizeToggle = () => {
setMinimized((current) => {
if (current) {
if (deviceMode === 'mobile' && !isMobileViewport) {
setPosition(mobileShellPositionRef.current);
}
return false;
}
if (isDesktopMobileShell) {
mobileShellPositionRef.current = position;
}
setPosition(minimizedPositionRef.current);
return true;
});
};
const handleActionButtonClick = (event: ReactMouseEvent<HTMLElement>) => {
event.stopPropagation();
};
return createPortal(
<div
ref={rootRef}
className={`preview-app-overlay${minimized ? ' preview-app-overlay--minimized' : ''}${
isDesktopMobileShell ? ' preview-app-overlay--mobile-shell' : ''
}`}
style={
minimized || isDesktopMobileShell
? {
left: `${position.x}px`,
top: `${position.y}px`,
}
: undefined
}
>
<div
className="preview-app-overlay__header"
onClick={() => {
if (minimized && !dragMovedRef.current) {
setMinimized(false);
}
}}
onPointerDown={handleHeaderPointerDown}
onPointerUp={handleHeaderPointerUp}
onPointerCancel={handleHeaderPointerUp}
>
<div className={`preview-app-overlay__title${minimized ? ' preview-app-overlay__title--minimized' : ''}`}>
{minimized ? (
<div className="preview-app-overlay__minimized-content">
<span className="preview-app-overlay__minimized-dot" aria-hidden="true" />
<span className="preview-app-overlay__minimized-label">Preview App</span>
</div>
) : (
<>
<span className="preview-app-overlay__title-badge" aria-hidden="true" />
<span className="preview-app-overlay__title-copy">
<strong>Preview App</strong>
<span> </span>
</span>
</>
)}
</div>
<div className="preview-app-overlay__actions">
{!minimized && !isMobileViewport ? (
<Button
type="text"
aria-label={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'}
title={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'}
icon={deviceMode === 'mobile' ? <DesktopOutlined /> : <MobileOutlined />}
onClick={(event) => {
handleActionButtonClick(event);
setDeviceMode((current) => (current === 'mobile' ? 'desktop' : 'mobile'));
}}
/>
) : null}
{!minimized ? (
<Button
type="text"
aria-label="Preview 최소화"
icon={<MinusOutlined />}
onClick={(event) => {
handleActionButtonClick(event);
handleMinimizeToggle();
}}
/>
) : null}
<Button
type="text"
danger
aria-label="Preview 닫기"
icon={<CloseOutlined />}
onClick={(event) => {
handleActionButtonClick(event);
dragMovedRef.current = false;
onClose();
}}
/>
</div>
</div>
<div className={`preview-app-overlay__body${minimized ? ' preview-app-overlay__body--hidden' : ''}`}>
<PreviewAppWindow
pathname={pathname}
search={search}
targetDescriptor={targetDescriptor}
deviceMode={deviceMode}
/>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,39 @@
import { useMemo } from 'react';
import { getRegisteredAccessToken } from './tokenAccess';
import { buildPreviewRuntimeUrl, type PreviewTargetDescriptor } from './previewRuntime';
type PreviewAppWindowProps = {
pathname: string;
search?: string;
targetDescriptor?: PreviewTargetDescriptor;
deviceMode?: 'desktop' | 'mobile';
};
export function PreviewAppWindow({
pathname,
search = '',
targetDescriptor = null,
deviceMode = 'desktop',
}: PreviewAppWindowProps) {
const previewUrl = useMemo(
() => buildPreviewRuntimeUrl(pathname, search, getRegisteredAccessToken(), targetDescriptor, deviceMode),
[deviceMode, pathname, search, targetDescriptor],
);
return (
<div
className={`preview-app-window preview-app-window--${deviceMode}`}
onPointerDown={(event) => event.stopPropagation()}
>
<div className={`preview-app-window__viewport preview-app-window__viewport--${deviceMode}`}>
<iframe
title="Preview App"
src={previewUrl}
className="preview-app-window__frame"
allow="clipboard-read; clipboard-write"
referrerPolicy="same-origin"
/>
</div>
</div>
);
}

0
src/app/main/ReleasePendingMainModal.tsx Executable file → Normal file
View File

View File

@@ -1,6 +1,8 @@
.resource-management-page {
position: relative;
display: grid;
flex: 1 1 0;
align-self: stretch;
grid-template-columns: minmax(220px, 0.82fr) minmax(280px, 1fr) minmax(300px, 1.08fr);
gap: 16px;
width: 100%;
@@ -9,6 +11,22 @@
min-height: 0;
overflow: hidden;
-webkit-touch-callout: none;
user-select: none;
-webkit-user-select: none;
}
.resource-management-page input,
.resource-management-page textarea,
.resource-management-page [contenteditable='true'],
.resource-management-page .ant-input,
.resource-management-page .ant-input textarea,
.resource-management-page .ant-input-affix-wrapper,
.resource-management-page .ant-tabs-tab-btn,
.resource-management-page .markdown-preview a,
.resource-management-page .markdown-preview code,
.resource-management-page .markdown-preview pre code {
user-select: text;
-webkit-user-select: text;
}
.resource-management-page__mobile-nav,
@@ -102,13 +120,33 @@
flex: 1;
min-height: 0;
overflow: auto;
padding-block: 14px 18px;
padding-right: 4px;
scrollbar-gutter: stable;
}
.resource-management-page__tree-region,
.resource-management-page__list-shell {
outline: none;
}
.resource-management-page__tree-region {
display: flex;
flex: 1 1 auto;
min-height: 0;
border-radius: 18px;
}
.resource-management-page__keyboard-region--active,
.resource-management-page__tree-region:focus-visible,
.resource-management-page__list-shell:focus-visible {
box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.36);
}
.resource-management-page__tree .ant-tree-node-content-wrapper {
width: 100%;
border-radius: 12px;
font-size: 13px;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
@@ -145,6 +183,7 @@
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
}
.resource-management-page__toolbar {
@@ -169,8 +208,23 @@
min-width: 0;
}
.resource-management-page__toolbar-status {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.resource-management-page__toolbar-search-group {
display: flex;
flex: 1 1 228px;
min-width: 0;
gap: 0;
}
.resource-management-page__toolbar-actions {
flex: 0 1 auto;
justify-content: flex-end;
}
.resource-management-page__toolbar-path {
@@ -178,11 +232,49 @@
flex: 1 1 240px;
}
.resource-management-page__toolbar-search {
flex: 1 1 auto;
min-width: 0;
}
.resource-management-page__toolbar-search-button.ant-btn,
.resource-management-page__toolbar-actions .ant-btn {
flex: 0 0 auto;
width: 40px;
min-width: 40px;
padding-inline: 0;
}
.resource-management-page__toolbar-path .ant-typography {
display: block;
margin-bottom: 0;
}
.resource-management-page__filter-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid #dbe4f4;
border-radius: 999px;
background: #f8fbff;
color: #64748b;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.resource-management-page__filter-badge--active {
border-color: rgba(37, 99, 235, 0.22);
background: rgba(219, 234, 254, 0.9);
color: #1d4ed8;
}
.resource-management-page__filter-summary {
color: #64748b;
font-size: 12px;
}
.resource-management-page__guide {
margin: 0;
}
@@ -219,15 +311,60 @@
font-weight: 600;
}
.resource-management-page__list-header-button,
.resource-management-page__list-header-action {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.resource-management-page__list-header-button {
justify-content: flex-start;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.resource-management-page__list-header-button:hover,
.resource-management-page__list-header-button:focus-visible {
color: #1d4ed8;
}
.resource-management-page__list-header-button--active {
color: #1d4ed8;
}
.resource-management-page__list-header-sort-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
min-width: 12px;
color: currentColor;
font-size: 10px;
}
.resource-management-page__list-header-action {
justify-content: center;
}
.resource-management-page__list-body {
flex: 1;
min-height: 0;
overflow: auto;
padding-block: 14px 18px;
scrollbar-gutter: stable;
}
.resource-management-page__list-row {
padding: 12px 16px;
padding: 14px 16px;
font-size: 13px;
border-bottom: 1px solid #eef2fa;
cursor: pointer;
transition: background-color 0.18s ease, transform 0.18s ease;
@@ -241,11 +378,35 @@
}
.resource-management-page__list-row--selected {
background: rgba(186, 209, 255, 0.34);
background: linear-gradient(180deg, rgba(213, 228, 255, 0.96), rgba(190, 214, 255, 0.98));
box-shadow:
inset 4px 0 0 #2563eb,
inset 0 0 0 1px rgba(37, 99, 235, 0.18);
}
.resource-management-page__list-row--parent {
background: rgba(241, 245, 255, 0.92);
background: linear-gradient(180deg, rgba(247, 250, 255, 0.98), rgba(242, 246, 255, 0.98));
color: #334155;
}
.resource-management-page__list-row--parent .resource-management-page__entry-icon {
color: #1d4ed8;
}
.resource-management-page__list-row--parent .resource-management-page__entry-name-text {
font-weight: 500;
}
.resource-management-page__list-row--parent-selected {
background: linear-gradient(180deg, rgba(213, 228, 255, 0.96), rgba(190, 214, 255, 0.98));
box-shadow:
inset 4px 0 0 #2563eb,
inset 0 0 0 1px rgba(37, 99, 235, 0.18);
}
.resource-management-page__list-row--parent-selected .resource-management-page__entry-icon,
.resource-management-page__list-row--parent-selected .resource-management-page__entry-name-text {
color: #1e3a8a;
}
.resource-management-page__list-name,
@@ -258,6 +419,33 @@
overflow: hidden;
}
.resource-management-page__entry-name-stack {
display: flex;
flex-direction: column;
min-width: 0;
}
.resource-management-page__entry-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 20px;
color: #476182;
font-size: 18px;
}
.resource-management-page__entry-branch-count {
flex: 0 0 auto;
padding: 1px 6px;
border-radius: 999px;
background: rgba(219, 234, 254, 0.92);
color: #1d4ed8;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.resource-management-page__list-meta {
display: contents;
}
@@ -269,12 +457,35 @@
white-space: nowrap;
}
.resource-management-page__modified-at {
display: inline-flex;
align-items: center;
gap: 0.4ch;
min-width: 0;
}
.resource-management-page__modified-at > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-management-page__entry-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-management-page__entry-context-text {
overflow: hidden;
color: #64748b;
font-size: 11px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-management-page__preview-card .ant-card-body {
display: flex;
flex-direction: column;
@@ -301,6 +512,8 @@
}
.resource-management-page__preview-meta .ant-typography {
display: block;
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
margin-bottom: 0;
@@ -308,6 +521,10 @@
word-break: break-word;
}
.resource-management-page__preview-copy {
flex: 0 1 auto;
}
.resource-management-page__preview-meta .ant-typography-copy {
overflow-wrap: anywhere;
word-break: break-word;
@@ -385,6 +602,17 @@
box-sizing: border-box;
}
.resource-management-page__video-preview {
width: 100%;
min-height: 0;
flex: 1;
border: 0;
border-radius: 16px;
background: #0b1220;
box-shadow: inset 0 0 0 1px #d9e1f2;
box-sizing: border-box;
}
.resource-management-page__html-preview {
display: flex;
flex-direction: column;
@@ -401,11 +629,17 @@
}
.resource-management-page__html-mode-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: stretch;
gap: 0;
margin-left: auto;
min-width: 0;
}
.resource-management-page__html-mode-switch .ant-btn {
min-width: 64px;
.resource-management-page__html-mode-button.ant-btn {
width: 100%;
min-width: 0;
}
.resource-management-page__text-preview {
@@ -443,13 +677,39 @@
}
.resource-management-page__rich-preview--markdown {
position: relative;
display: flex;
overflow: hidden;
padding: 0;
contain: layout paint;
isolation: isolate;
}
.resource-management-page__markdown-scroll-viewport {
flex: 1 1 auto;
width: 100%;
min-width: 0;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: none;
overscroll-behavior-y: contain;
scrollbar-gutter: stable;
padding: 16px;
box-sizing: border-box;
touch-action: pan-y;
transform: translateZ(0);
}
.resource-management-page__rich-preview--markdown .markdown-preview {
width: 100%;
min-width: 0;
min-height: 100%;
margin-bottom: 0;
overflow: visible;
padding: 0;
box-sizing: border-box;
overflow-wrap: anywhere;
word-break: break-word;
}
@@ -655,6 +915,7 @@
.resource-management-page__preview-modal-body {
overflow: hidden;
background: #0b1220;
overscroll-behavior: none;
}
.resource-management-page__preview-modal-shell {
@@ -662,6 +923,7 @@
flex-direction: column;
height: 100%;
min-height: 0;
overscroll-behavior: none;
}
.resource-management-page__preview-modal-shell .resource-management-page__preview-modal-body {
@@ -742,29 +1004,60 @@
inset: auto auto -100vh -100vw;
}
@media (max-width: 1180px) {
.resource-management-page {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.resource-management-page--compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: minmax(0, 1fr);
}
.resource-management-page__preview-card {
grid-column: 1 / -1;
}
.resource-management-page--compact > .resource-management-page__preview-card {
display: none;
}
.resource-management-page--compact .resource-management-page__modified-at {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
line-height: 1.25;
}
.resource-management-page--compact.resource-management-page--has-preview > .resource-management-page__sidebar {
display: none;
}
.resource-management-page--compact.resource-management-page--has-preview > .resource-management-page__preview-card {
display: flex;
}
@media (max-width: 1180px) {
}
@media (max-width: 768px) {
.resource-management-page {
display: flex;
flex-direction: column;
gap: 10px;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
gap: 0;
flex: 1 1 0;
align-content: stretch;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.resource-management-page--mobile {
flex: 1 1 auto !important;
align-self: stretch;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 0;
padding-inline: 0;
padding-bottom: max(6px, calc(env(safe-area-inset-bottom, 0px) + 2px));
max-height: 100%;
padding: 0;
box-sizing: border-box;
overflow: hidden;
}
.resource-management-page--mobile > .resource-management-page__sidebar,
@@ -776,48 +1069,67 @@
.resource-management-page__mobile-nav {
position: sticky;
top: 0;
z-index: 2;
z-index: 3;
display: grid;
grid-row: 1;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
gap: 3px;
flex: 0 0 auto;
padding: 6px;
border-radius: 18px;
background: rgba(244, 247, 252, 0.96);
box-shadow: inset 0 0 0 1px rgba(217, 225, 242, 0.92);
padding: 4px 10px 1px;
border-radius: 0;
background: #eef4ff;
box-shadow: none;
backdrop-filter: blur(12px);
}
.resource-management-page__mobile-card {
display: flex;
flex: 1 1 auto;
grid-row: 2;
grid-column: 1 / -1;
flex: 1 1 0;
min-height: 0;
height: 100%;
max-height: 100%;
overflow: hidden;
box-sizing: border-box;
}
.resource-management-page__mobile-card > .ant-card {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
height: 100%;
border-radius: 20px;
box-shadow: inset 0 0 0 1px rgba(191, 204, 229, 0.9);
max-height: 100%;
border: 0;
border-top: 0;
border-radius: 0;
overflow: hidden;
box-shadow: none;
}
.resource-management-page__mobile-card > .ant-card .ant-card-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.resource-management-page__mobile-nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
gap: 5px;
width: 100%;
min-width: 0;
min-height: 42px;
padding: 10px 8px;
min-height: 36px;
padding: 6px 6px;
border: 0;
border-radius: 14px;
background: transparent;
color: #52607a;
font: inherit;
font-size: 13px;
font-size: 11px;
font-weight: 600;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
@@ -839,7 +1151,7 @@
align-items: center;
justify-content: center;
flex: 0 0 auto;
font-size: 14px;
font-size: 16px;
}
.resource-management-page__mobile-nav-button-label {
@@ -851,12 +1163,64 @@
.resource-management-page__sidebar .ant-card-body,
.resource-management-page__content .ant-card-body,
.resource-management-page__preview-card .ant-card-body {
padding: 14px;
padding-bottom: 14px;
padding: 16px;
padding-bottom: max(14px, calc(env(safe-area-inset-bottom, 0px) + 10px));
overflow: hidden;
}
.resource-management-page__sidebar .ant-card-head,
.resource-management-page__content .ant-card-head,
.resource-management-page__preview-card .ant-card-head {
min-height: 54px;
padding-inline: 16px;
padding-top: 8px;
padding-bottom: 6px;
}
.resource-management-page__sidebar .ant-card-head-title,
.resource-management-page__content .ant-card-head-title,
.resource-management-page__preview-card .ant-card-head-title {
padding: 0;
}
.resource-management-page__list-header {
display: none;
grid-template-columns: minmax(0, 1fr) 56px;
grid-template-areas:
'name action'
'modified size';
row-gap: 6px;
padding: 12px 16px 10px;
}
.resource-management-page__list-header-button[data-sort-key='name'] {
grid-area: name;
}
.resource-management-page__list-header-button[data-sort-key='modifiedAt'] {
grid-area: modified;
}
.resource-management-page__list-header-button[data-sort-key='size'] {
grid-area: size;
}
.resource-management-page__list-header-action {
grid-area: action;
justify-self: end;
}
.resource-management-page__list-shell {
flex: 1 1 auto;
min-height: 0;
max-height: none;
overflow: hidden;
}
.resource-management-page__list-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding-block: 14px calc(env(safe-area-inset-bottom, 0px) + 18px);
}
.resource-management-page__list-row {
@@ -864,7 +1228,8 @@
grid-template-areas:
'name actions'
'meta meta';
row-gap: 6px;
row-gap: 4px;
padding: 14px 16px;
}
.resource-management-page__list-name {
@@ -875,7 +1240,7 @@
grid-area: meta;
display: flex;
justify-content: space-between;
gap: 12px;
gap: 10px;
font-size: 12px;
color: #6b7280;
}
@@ -884,6 +1249,11 @@
font-size: 12px;
}
.resource-management-page__list-header-button,
.resource-management-page__list-header-action {
font-size: 11px;
}
.resource-management-page__list-row > .ant-space {
grid-area: actions;
justify-self: end;
@@ -894,6 +1264,7 @@
}
.resource-management-page__tree {
padding-block: 14px calc(env(safe-area-inset-bottom, 0px) + 20px);
padding-right: 0;
}
@@ -903,8 +1274,44 @@
min-width: 0;
}
.resource-management-page__tree .ant-tree-treenode {
align-items: center;
}
.resource-management-page__tree .ant-tree-node-content-wrapper {
padding-block: 6px;
font-size: 13px;
}
.resource-management-page__tree-title {
gap: 6px;
}
.resource-management-page__entry-icon {
width: 22px;
font-size: 19px;
}
.resource-management-page__entry-name {
gap: 6px;
}
.resource-management-page__entry-name-text {
font-size: 13px;
}
.resource-management-page__entry-branch-count {
padding: 2px 8px;
font-size: 12px;
}
.resource-management-page__tree .ant-tree-switcher {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: center;
flex: 0 0 auto;
min-height: 100%;
}
.resource-management-page__toolbar {
@@ -916,12 +1323,30 @@
width: 100%;
}
.resource-management-page__toolbar-main {
display: contents;
}
.resource-management-page__toolbar-search-group {
flex: 1 1 100%;
}
.resource-management-page__toolbar-search {
flex: 1 1 auto;
}
.resource-management-page__toolbar-search-button.ant-btn {
width: 44px;
min-width: 44px;
}
.resource-management-page__toolbar-path {
min-width: 100%;
display: none;
}
.resource-management-page__toolbar-actions .ant-btn {
flex: 1 1 0;
width: 44px;
min-width: 44px;
}
.resource-management-page__preview-meta {
@@ -931,12 +1356,14 @@
}
.resource-management-page__preview-meta .ant-typography,
.resource-management-page__preview-meta .ant-space-compact {
.resource-management-page__preview-copy,
.resource-management-page__html-mode-switch {
width: 100%;
max-width: 100%;
}
.resource-management-page__preview-frame,
.resource-management-page__video-preview,
.resource-management-page__image-preview,
.resource-management-page__text-preview,
.resource-management-page__rich-preview {
@@ -948,8 +1375,25 @@
width: 100%;
}
.resource-management-page__html-mode-switch .ant-btn {
.resource-management-page__html-mode-button.ant-btn {
flex: 1 1 0;
min-width: 0;
}
.resource-management-page__preview-tabs .ant-tabs-nav-list {
width: 100%;
}
.resource-management-page__preview-tabs .ant-tabs-tab {
flex: 1 1 0;
justify-content: center;
min-width: 0;
margin: 0;
}
.resource-management-page__preview-tabs .ant-tabs-tab-btn {
justify-content: center;
width: 100%;
}
.resource-management-page__editor {
@@ -967,7 +1411,7 @@
.resource-management-page__preview-card .ant-tabs-content-holder {
overflow: hidden;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 10px);
padding-bottom: 0;
box-sizing: border-box;
}
@@ -977,7 +1421,14 @@
}
.resource-management-page__editor-panel {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 6px);
padding-bottom: 0;
}
.resource-management-page__tree,
.resource-management-page__list-body,
.resource-management-page__text-preview,
.resource-management-page__rich-preview {
scroll-padding-bottom: max(14px, calc(env(safe-area-inset-bottom, 0px) + 10px));
}
.resource-management-page__editor-actions {
@@ -1003,11 +1454,53 @@
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile {
position: fixed;
inset: 0;
overflow: hidden;
overscroll-behavior: none;
touch-action: none;
contain: strict;
isolation: isolate;
}
.resource-management-page__preview-modal-wrap--mobile.ant-modal-root {
position: fixed;
inset: 0;
overflow: hidden;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-mask,
.resource-management-page__preview-modal-wrap--mobile .ant-modal-wrap {
position: fixed;
inset: 0;
overflow: hidden;
touch-action: none;
overscroll-behavior: none;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-wrap,
.resource-management-page__preview-modal-wrap--mobile .ant-modal-body,
.resource-management-page__preview-modal-shell .resource-management-page__text-preview,
.resource-management-page__preview-modal-shell .resource-management-page__rich-preview {
overscroll-behavior: none;
-webkit-overflow-scrolling: auto;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-content {
display: flex;
flex-direction: column;
width: 100vw;
border-radius: 0;
min-height: 100dvh;
height: 100vh;
height: 100svh;
min-height: 100vh;
min-height: 100svh;
background: #0b1220;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
contain: strict;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-header {
@@ -1038,14 +1531,26 @@
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-body {
height: calc(100dvh - 56px) !important;
flex: 1 1 auto;
height: calc(100vh - 56px) !important;
height: calc(100svh - 56px) !important;
min-height: 0;
background: #0b1220;
overflow: hidden;
overscroll-behavior: none;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-shell .resource-management-page__preview-modal-body {
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-shell,
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-body {
overflow: hidden;
overscroll-behavior: none;
contain: strict;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__zoom-shell {
border: 0;
border-radius: 0;
@@ -1056,7 +1561,22 @@
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__rich-preview--markdown,
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__rich-preview--markdown {
overscroll-behavior: none;
-webkit-overflow-scrolling: auto;
contain: strict;
height: 100%;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__markdown-scroll-viewport {
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-frame,
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__video-preview,
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__image-preview {
border: 0;
border-radius: 0;

File diff suppressed because it is too large Load Diff

7
src/app/main/appConfig.ts Executable file → Normal file
View File

@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react';
import { appendClientIdHeader } from './clientIdentity';
import { getAutomationNotificationPreferenceTarget } from './notificationIdentity';
import { isPreviewRuntime } from './previewRuntime';
export const APP_CONFIG_STORAGE_KEY = 'work-server.app-config';
const APP_CONFIG_EVENT = 'work-server:app-config';
@@ -651,7 +652,8 @@ export function getStoredAppConfig(): AppConfig {
}
try {
const raw = window.localStorage.getItem(APP_CONFIG_STORAGE_KEY);
const storage = isPreviewRuntime() ? window.sessionStorage : window.localStorage;
const raw = storage.getItem(APP_CONFIG_STORAGE_KEY);
if (!raw) {
cachedConfig = DEFAULT_APP_CONFIG;
@@ -683,7 +685,8 @@ export function setStoredAppConfig(config: AppConfig) {
const raw = JSON.stringify(normalized);
cachedConfig = normalized;
cachedRawConfig = raw;
window.localStorage.setItem(APP_CONFIG_STORAGE_KEY, raw);
const storage = isPreviewRuntime() ? window.sessionStorage : window.localStorage;
storage.setItem(APP_CONFIG_STORAGE_KEY, raw);
emitConfigChange();
}

View File

@@ -1,5 +1,5 @@
import { CLIENT_ID_STORAGE_KEY } from './clientIdentity';
import { NOTIFICATION_DEVICE_ID_STORAGE_KEY, PWA_NOTIFICATION_TOKEN_STORAGE_KEY } from './notificationIdentity';
import { NOTIFICATION_DEVICE_ID_STORAGE_KEY } from './notificationIdentity';
import { TOKEN_ACCESS_STORAGE_KEY } from './tokenAccess';
import { APP_CONFIG_STORAGE_KEY } from './appConfig';
@@ -8,7 +8,6 @@ const PRESERVED_LOCAL_STORAGE_KEYS = new Set([
TOKEN_ACCESS_STORAGE_KEY,
CLIENT_ID_STORAGE_KEY,
NOTIFICATION_DEVICE_ID_STORAGE_KEY,
PWA_NOTIFICATION_TOKEN_STORAGE_KEY,
]);
const APP_LOCAL_STORAGE_PREFIXES = ['work-', 'main-chat-panel:', 'gps-layer:', 'ai-code-app:'] as const;

0
src/app/main/appUpdate.ts Executable file → Normal file
View File

View File

@@ -4,6 +4,7 @@ import { appendClientIdHeader } from './clientIdentity';
export type ChatDefaultContextRecord = {
id: string;
title: string;
sortOrder: number;
content: string;
enabled: boolean;
updatedAt: string;
@@ -50,6 +51,15 @@ function compareUpdatedAt(left: { updatedAt: string }, right: { updatedAt: strin
return 0;
}
function normalizePositiveSortOrder(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return Number.NaN;
}
const nextValue = Math.trunc(value);
return nextValue > 0 ? nextValue : Number.NaN;
}
function normalizeDefaultContext(record: Partial<ChatDefaultContextRecord>): ChatDefaultContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
@@ -63,6 +73,7 @@ function normalizeDefaultContext(record: Partial<ChatDefaultContextRecord>): Cha
normalizeText(record.id) ||
`chat-default-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
title: title || '기본 유형',
sortOrder: normalizePositiveSortOrder(record.sortOrder),
content,
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
@@ -87,7 +98,35 @@ function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | nu
}
});
return Array.from(byId.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
return Array.from(byId.values())
.sort((left, right) => {
const leftSortOrder = Number.isFinite(left.sortOrder) ? left.sortOrder : null;
const rightSortOrder = Number.isFinite(right.sortOrder) ? right.sortOrder : null;
if (leftSortOrder !== null && rightSortOrder !== null && leftSortOrder !== rightSortOrder) {
return leftSortOrder - rightSortOrder;
}
if (leftSortOrder !== null && rightSortOrder === null) {
return -1;
}
if (leftSortOrder === null && rightSortOrder !== null) {
return 1;
}
const titleCompare = left.title.localeCompare(right.title, 'ko-KR');
if (titleCompare !== 0) {
return titleCompare;
}
return compareUpdatedAt(left, right);
})
.map((item, index) => ({
...item,
sortOrder: index + 1,
}));
}
function sanitizeChatTypeDefaultSelections(items: Partial<ChatTypeDefaultContextSelection>[] | null | undefined) {
@@ -443,8 +482,15 @@ export function upsertChatDefaultContext(
defaultContexts: ChatDefaultContextRecord[],
input: Partial<ChatDefaultContextRecord>,
) {
const currentRecord = defaultContexts.find((item) => item.id === normalizeText(input.id)) ?? null;
const nextSortOrder =
normalizePositiveSortOrder(input.sortOrder) ||
currentRecord?.sortOrder ||
Math.max(0, ...defaultContexts.map((item) => item.sortOrder || 0)) + 1;
const nextRecord = normalizeDefaultContext({
...currentRecord,
...input,
sortOrder: nextSortOrder,
updatedAt: new Date().toISOString(),
});

138
src/app/main/chatTypeAccess.ts Executable file → Normal file
View File

@@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
import { DEFAULT_CHAT_TYPES } from './chatTypeDefaults';
export type ChatPermissionRole = 'guest' | 'token-user';
export type ChatTypeRecord = {
id: string;
name: string;
sortOrder: number;
description: string;
permissions: ChatPermissionRole[];
enabled: boolean;
@@ -14,14 +14,13 @@ export type ChatTypeRecord = {
};
export type ChatTypeRegistrySnapshot = {
builtInChatTypes: ChatTypeRecord[];
customChatTypes: ChatTypeRecord[];
chatTypes: ChatTypeRecord[];
};
export type ChatTypeInput = {
id?: string;
name: string;
sortOrder?: number;
description?: string;
permissions: ChatPermissionRole[];
enabled?: boolean;
@@ -54,7 +53,20 @@ function normalizePermissions(permissions: ChatPermissionRole[] | null | undefin
return nextPermissions.length > 0 ? nextPermissions : (['token-user'] as ChatPermissionRole[]);
}
function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | null {
function normalizeSortOrder(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
const nextValue = Math.trunc(value);
return nextValue > 0 ? nextValue : null;
}
type NormalizedChatTypeCandidate = Omit<ChatTypeRecord, 'sortOrder'> & {
sortOrder: number | null;
};
function normalizeChatType(record: Partial<ChatTypeRecord>): NormalizedChatTypeCandidate | null {
const name = normalizeText(record.name);
if (!name) {
@@ -68,6 +80,7 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | nu
return {
id,
name,
sortOrder: normalizeSortOrder(record.sortOrder),
description: normalizeText(record.description),
permissions: normalizePermissions(record.permissions),
enabled: record.enabled !== false,
@@ -79,7 +92,7 @@ function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
return buildChatTypeNameKey(record.name);
}
function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
function compareUpdatedAt(left: Pick<ChatTypeRecord, 'updatedAt'>, right: Pick<ChatTypeRecord, 'updatedAt'>) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
@@ -90,8 +103,8 @@ function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
return 0;
}
function dedupeChatTypes(chatTypes: ChatTypeRecord[]) {
const bySemanticKey = new Map<string, ChatTypeRecord>();
function dedupeChatTypes(chatTypes: NormalizedChatTypeCandidate[]) {
const bySemanticKey = new Map<string, NormalizedChatTypeCandidate>();
for (const item of chatTypes) {
const semanticKey = getChatTypeSemanticKey(item);
@@ -105,26 +118,45 @@ function dedupeChatTypes(chatTypes: ChatTypeRecord[]) {
bySemanticKey.set(semanticKey, compareUpdatedAt(current, item) <= 0 ? item : current);
}
return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
return Array.from(bySemanticKey.values())
.sort((left, right) => {
const leftSortOrder = normalizeSortOrder(left.sortOrder);
const rightSortOrder = normalizeSortOrder(right.sortOrder);
if (leftSortOrder !== null && rightSortOrder !== null && leftSortOrder !== rightSortOrder) {
return leftSortOrder - rightSortOrder;
}
if (leftSortOrder !== null && rightSortOrder === null) {
return -1;
}
if (leftSortOrder === null && rightSortOrder !== null) {
return 1;
}
const nameCompare = left.name.localeCompare(right.name, 'ko-KR');
if (nameCompare !== 0) {
return nameCompare;
}
return compareUpdatedAt(left, right);
})
.map((item, index) => ({
...item,
sortOrder: index + 1,
}));
}
function sanitizeChatTypes(chatTypes: Partial<ChatTypeRecord>[]) {
return dedupeChatTypes(
chatTypes
.map((item) => normalizeChatType(item))
.filter((item): item is ChatTypeRecord => Boolean(item)),
.filter((item): item is NormalizedChatTypeCandidate => Boolean(item)),
);
}
function mergeWithDefaultChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null | undefined) {
return sanitizeChatTypes([...(chatTypes ?? []), ...DEFAULT_CHAT_TYPES]);
}
function stripBuiltInChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null | undefined) {
const builtInIds = new Set(DEFAULT_CHAT_TYPES.map((item) => item.id));
return sanitizeChatTypes(chatTypes ?? []).filter((item) => !builtInIds.has(item.id));
}
function emitChatTypesChange() {
if (typeof window === 'undefined') {
return;
@@ -213,55 +245,28 @@ async function requestChatTypes<T>(init?: RequestInit) {
async function fetchChatTypesFromServer() {
const response = await requestChatTypes<{
ok: boolean;
builtInChatTypes?: Partial<ChatTypeRecord>[] | null;
customChatTypes?: Partial<ChatTypeRecord>[] | null;
chatTypes?: Partial<ChatTypeRecord>[] | null;
}>({
method: 'GET',
});
const builtInChatTypes =
response.builtInChatTypes != null ? sanitizeChatTypes(response.builtInChatTypes) : sanitizeChatTypes(DEFAULT_CHAT_TYPES);
const customChatTypes =
response.customChatTypes != null
? stripBuiltInChatTypes(response.customChatTypes)
: stripBuiltInChatTypes(response.chatTypes);
const chatTypes =
response.chatTypes != null ? mergeWithDefaultChatTypes(response.chatTypes) : mergeWithDefaultChatTypes(customChatTypes);
return {
builtInChatTypes,
customChatTypes,
chatTypes,
chatTypes: sanitizeChatTypes(response.chatTypes ?? []),
} satisfies ChatTypeRegistrySnapshot;
}
async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
const customChatTypes = stripBuiltInChatTypes(chatTypes);
const response = await requestChatTypes<{
ok: boolean;
builtInChatTypes?: Partial<ChatTypeRecord>[] | null;
customChatTypes?: Partial<ChatTypeRecord>[] | null;
chatTypes?: Partial<ChatTypeRecord>[] | null;
}>({
method: 'PUT',
body: JSON.stringify({ customChatTypes }),
body: JSON.stringify({ chatTypes: sanitizeChatTypes(chatTypes) }),
});
emitChatTypesChange();
const builtInChatTypes =
response.builtInChatTypes != null ? sanitizeChatTypes(response.builtInChatTypes) : sanitizeChatTypes(DEFAULT_CHAT_TYPES);
const nextCustomChatTypes =
response.customChatTypes != null
? stripBuiltInChatTypes(response.customChatTypes)
: stripBuiltInChatTypes(response.chatTypes);
const nextChatTypes =
response.chatTypes != null ? mergeWithDefaultChatTypes(response.chatTypes) : mergeWithDefaultChatTypes(nextCustomChatTypes);
return {
builtInChatTypes,
customChatTypes: nextCustomChatTypes,
chatTypes: nextChatTypes,
chatTypes: sanitizeChatTypes(response.chatTypes ?? []),
} satisfies ChatTypeRegistrySnapshot;
}
@@ -269,6 +274,7 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
const nextRecord = normalizeChatType({
id: input.id,
name: input.name,
sortOrder: input.sortOrder,
description: input.description,
permissions: input.permissions,
enabled: input.enabled,
@@ -280,10 +286,13 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
}
const nextSemanticKey = getChatTypeSemanticKey(nextRecord);
const nextChatTypes = chatTypes.filter(
const nextChatTypes: Partial<ChatTypeRecord>[] = chatTypes.filter(
(item) => item.id !== nextRecord.id && getChatTypeSemanticKey(item) !== nextSemanticKey,
);
nextChatTypes.push(nextRecord);
nextChatTypes.push({
...nextRecord,
sortOrder: nextRecord.sortOrder ?? chatTypes.length + 1,
});
return sanitizeChatTypes(nextChatTypes);
}
@@ -306,16 +315,12 @@ export function canUseChatType(chatType: ChatTypeRecord, roles: ChatPermissionRo
}
export function useChatTypeRegistry() {
const [chatTypes, setChatTypesState] = useState<ChatTypeRecord[]>(DEFAULT_CHAT_TYPES);
const [builtInChatTypes, setBuiltInChatTypesState] = useState<ChatTypeRecord[]>(sanitizeChatTypes(DEFAULT_CHAT_TYPES));
const [customChatTypes, setCustomChatTypesState] = useState<ChatTypeRecord[]>([]);
const [chatTypes, setChatTypesState] = useState<ChatTypeRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const isMountedRef = useRef(true);
const syncChatTypesRef = useRef<() => Promise<ChatTypeRegistrySnapshot>>(async () => ({
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
customChatTypes: [],
chatTypes: DEFAULT_CHAT_TYPES,
chatTypes: [],
}));
const applySnapshot = (snapshot: ChatTypeRegistrySnapshot) => {
@@ -323,8 +328,6 @@ export function useChatTypeRegistry() {
return;
}
setBuiltInChatTypesState(snapshot.builtInChatTypes);
setCustomChatTypesState(snapshot.customChatTypes);
setChatTypesState(snapshot.chatTypes);
setErrorMessage('');
};
@@ -336,9 +339,7 @@ export function useChatTypeRegistry() {
setIsLoading(true);
setErrorMessage('');
let resolvedChatTypeSnapshot: ChatTypeRegistrySnapshot = {
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
customChatTypes: [],
chatTypes: DEFAULT_CHAT_TYPES,
chatTypes: [],
};
try {
@@ -347,15 +348,11 @@ export function useChatTypeRegistry() {
applySnapshot(resolvedChatTypeSnapshot);
} catch (error) {
if (isMountedRef.current) {
setBuiltInChatTypesState(sanitizeChatTypes(DEFAULT_CHAT_TYPES));
setCustomChatTypesState([]);
setChatTypesState(DEFAULT_CHAT_TYPES);
setChatTypesState([]);
setErrorMessage(error instanceof Error ? error.message : '채팅유형을 불러오지 못했습니다.');
}
resolvedChatTypeSnapshot = {
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
customChatTypes: [],
chatTypes: DEFAULT_CHAT_TYPES,
chatTypes: [],
};
} finally {
if (isMountedRef.current) {
@@ -384,11 +381,14 @@ export function useChatTypeRegistry() {
return {
chatTypes,
builtInChatTypes,
customChatTypes,
isLoading,
errorMessage,
reload: async () => syncChatTypesRef.current(),
setChatTypesLocal: (nextChatTypes: ChatTypeRecord[]) => {
applySnapshot({
chatTypes: sanitizeChatTypes(nextChatTypes),
});
},
setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => {
await saveChatTypesToServer(nextChatTypes);
const resolved = await fetchChatTypesFromServer();

View File

@@ -39,6 +39,7 @@ export function ConversationRoomPane({
return (
<ChatConversationView
sessionId={sessionId}
viewportRef={viewportRef}
composerRef={composerRef}
visibleMessages={messages}
@@ -77,6 +78,8 @@ export function ConversationRoomPane({
onSend={() => {}}
onSendImmediate={() => {}}
onToggleSendWithoutContext={() => {}}
isImmediateSendPinned={false}
onToggleImmediateSendPinned={() => {}}
onClearDraft={() => {}}
onScrollToBottom={() => {}}
onToggleResourceStrip={() => {}}

View File

@@ -36,8 +36,10 @@ export type ChatGateway = {
createConversation: (args: {
sessionId: string;
title: string;
requestBadgeLabel?: string | null;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string;
contextDescription?: string;
notifyOffline?: boolean;
@@ -49,6 +51,7 @@ export type ChatGateway = {
Pick<
ChatConversationSummary,
| 'title'
| 'requestBadgeLabel'
| 'chatTypeId'
| 'lastChatTypeId'
| 'generalSectionName'

View File

@@ -0,0 +1,221 @@
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { resolveConversationUnreadMergeState } from '../../mainChatPanel/conversationUnread';
function shouldPreserveRequestMetadata(
previousItem: Pick<ChatConversationSummary, 'currentRequestId'>,
nextItem: Pick<ChatConversationSummary, 'currentRequestId'>,
) {
const previousRequestId = previousItem.currentRequestId?.trim() || '';
const nextRequestId = nextItem.currentRequestId?.trim() || '';
return Boolean(previousRequestId && previousRequestId === nextRequestId);
}
function toConversationSortTime(value: string | null | undefined) {
if (typeof value !== 'string' || !value.trim()) {
return 0;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
function getConversationLastMessageSortTime(item: ChatConversationSummary) {
const lastMessageTime = toConversationSortTime(item.lastMessageAt);
if (lastMessageTime > 0) {
return lastMessageTime;
}
return Math.max(toConversationSortTime(item.createdAt), toConversationSortTime(item.updatedAt));
}
function pickPreferredConversationSummary(left: ChatConversationSummary, right: ChatConversationSummary) {
const leftTime = getConversationLastMessageSortTime(left);
const rightTime = getConversationLastMessageSortTime(right);
if (rightTime !== leftTime) {
return rightTime > leftTime ? right : left;
}
const leftUpdatedAt = toConversationSortTime(left.updatedAt);
const rightUpdatedAt = toConversationSortTime(right.updatedAt);
if (rightUpdatedAt !== leftUpdatedAt) {
return rightUpdatedAt > leftUpdatedAt ? right : left;
}
return right;
}
function mergeConversationSummaries(existing: ChatConversationSummary, incoming: ChatConversationSummary) {
const preferred = pickPreferredConversationSummary(existing, incoming);
const fallback = preferred === existing ? incoming : existing;
return {
...fallback,
...preferred,
clientId: preferred.clientId ?? fallback.clientId,
isDraftOnly: preferred.isDraftOnly ?? fallback.isDraftOnly,
title: preferred.title.trim() || fallback.title.trim(),
requestBadgeLabel: preferred.requestBadgeLabel?.trim() || fallback.requestBadgeLabel?.trim() || null,
chatTypeId: preferred.chatTypeId?.trim() || fallback.chatTypeId?.trim() || null,
lastChatTypeId: preferred.lastChatTypeId?.trim() || fallback.lastChatTypeId?.trim() || null,
generalSectionName: preferred.generalSectionName?.trim() || fallback.generalSectionName?.trim() || null,
contextLabel: preferred.contextLabel?.trim() || fallback.contextLabel?.trim() || null,
contextDescription: preferred.contextDescription?.trim() || fallback.contextDescription?.trim() || null,
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus,
currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null,
currentQueueSize: Math.max(preferred.currentQueueSize ?? 0, fallback.currentQueueSize ?? 0),
currentStatusUpdatedAt:
preferred.currentStatusUpdatedAt?.trim() || fallback.currentStatusUpdatedAt?.trim() || null,
isPendingWork: preferred.isPendingWork ?? fallback.isPendingWork,
pendingWorkReason: preferred.pendingWorkReason ?? fallback.pendingWorkReason,
lastRequestPreview: preferred.lastRequestPreview.trim() || fallback.lastRequestPreview.trim(),
lastMessagePreview: preferred.lastMessagePreview.trim() || fallback.lastMessagePreview.trim(),
lastResponsePreview: preferred.lastResponsePreview.trim() || fallback.lastResponsePreview.trim(),
createdAt: preferred.createdAt.trim() || fallback.createdAt.trim(),
updatedAt: preferred.updatedAt.trim() || fallback.updatedAt.trim(),
lastMessageAt: preferred.lastMessageAt?.trim() || fallback.lastMessageAt?.trim() || null,
};
}
function sortChatConversationSummaries(items: ChatConversationSummary[]) {
const dedupedItems = items.reduce<ChatConversationSummary[]>((result, item) => {
const sessionId = item.sessionId.trim();
if (!sessionId) {
result.push(item);
return result;
}
const existingIndex = result.findIndex((candidate) => candidate.sessionId.trim() === sessionId);
if (existingIndex < 0) {
result.push(item);
return result;
}
const nextItems = [...result];
nextItems[existingIndex] = mergeConversationSummaries(nextItems[existingIndex] as ChatConversationSummary, item);
return nextItems;
}, []);
return dedupedItems.sort((left, right) => {
const leftTime = getConversationLastMessageSortTime(left);
const rightTime = getConversationLastMessageSortTime(right);
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return left.sessionId.localeCompare(right.sessionId, 'ko-KR');
});
}
export function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[],
requestedSessionId: string,
) {
const previousBySessionId = new Map(previousItems.map((item) => [item.sessionId, item] as const));
const normalizedNextItems = nextItems.map((item) => {
const previousItem = previousBySessionId.get(item.sessionId);
if (!previousItem) {
return item;
}
const preserveRequestMetadata = shouldPreserveRequestMetadata(previousItem, item);
const chatTypeId = item.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null;
const lastChatTypeId =
item.lastChatTypeId?.trim() ||
chatTypeId ||
previousItem.lastChatTypeId?.trim() ||
previousItem.chatTypeId?.trim() ||
null;
return {
...item,
title: preserveRequestMetadata
? previousItem.title.trim() || item.title.trim()
: item.title.trim() || previousItem.title.trim(),
requestBadgeLabel: preserveRequestMetadata
? previousItem.requestBadgeLabel?.trim() || item.requestBadgeLabel?.trim() || null
: item.requestBadgeLabel?.trim() || previousItem.requestBadgeLabel?.trim() || null,
chatTypeId,
lastChatTypeId,
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
contextLabel: item.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
contextDescription: item.contextDescription?.trim() || null,
lastRequestPreview: item.lastRequestPreview.trim() || previousItem.lastRequestPreview.trim(),
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
currentRequestId:
item.currentRequestId?.trim() ||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentRequestId : null) ||
null,
currentJobStatus:
item.currentJobStatus ??
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
? previousItem.currentJobStatus
: null),
currentJobMessage:
item.currentJobMessage?.trim() ||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentJobMessage?.trim() : '') ||
null,
currentQueueSize:
item.currentQueueSize > 0
? item.currentQueueSize
: item.currentJobStatus === 'queued'
? Math.max(1, previousItem.currentQueueSize)
: previousItem.currentJobStatus === 'queued' && item.currentJobStatus == null
? Math.max(1, previousItem.currentQueueSize)
: item.currentQueueSize,
currentStatusUpdatedAt:
item.currentStatusUpdatedAt ||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
? previousItem.currentStatusUpdatedAt
: null),
};
});
const normalizedRequestedSessionId = requestedSessionId.trim();
const nextSessionIds = new Set(normalizedNextItems.map((item) => item.sessionId));
const preservedTransientItems = previousItems.filter((item) => {
if (!item.sessionId || nextSessionIds.has(item.sessionId)) {
return false;
}
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
});
if (!normalizedRequestedSessionId) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const hasRequestedSession = normalizedNextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
if (hasRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const preservedRequestedSession =
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
if (!preservedRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const hasPreservedRequestedSession = preservedTransientItems.some(
(item) => item.sessionId === preservedRequestedSession.sessionId,
);
if (hasPreservedRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
return sortChatConversationSummaries([preservedRequestedSession, ...preservedTransientItems, ...normalizedNextItems]);
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react';
import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
import { buildComposerFilePickKey } from '../../mainChatPanel/composerFilePickKey';
export type ComposerFilePickResult = {
items: {
@@ -11,19 +12,26 @@ export type ComposerFilePickResult = {
}[];
};
function buildComposerFilePickKey(file: File) {
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
}
type PendingChatRequest = {
sessionId: string;
requestId: string;
text: string;
mode: 'queue' | 'direct';
origin?: 'composer' | 'prompt';
parentRequestId?: string | null;
omitPromptHistory?: boolean;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeBaseDescription?: string;
defaultContextIds?: string[];
defaultContexts?: Array<{
id: string;
title: string;
content: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
retryCount: number;
failed: boolean;
};
@@ -31,9 +39,20 @@ type PendingChatRequest = {
type PendingContextConfirm = {
mode: 'queue' | 'direct';
text: string;
origin?: 'composer' | 'prompt';
parentRequestId?: string | null;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeBaseDescription?: string;
defaultContextIds?: string[];
defaultContexts?: Array<{
id: string;
title: string;
content: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
includedContextCount: number;
omittedContextCount: number;
omitPromptHistory?: boolean;
@@ -43,6 +62,15 @@ type SelectedChatType = {
id: string;
name: string;
description: string;
baseDescription?: string;
defaultContextIds?: string[];
defaultContexts?: Array<{
id: string;
title: string;
content: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
} | null;
type RecentContextSummary = {
@@ -64,6 +92,7 @@ type UseConversationComposerControllerOptions = {
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
messagesRef: { current: ChatMessage[] };
pendingRequestsRef: { current: PendingChatRequest[] };
promptRequestIdsRef?: { current: Set<string> };
shouldStickToBottomRef: { current: boolean };
setDraft: (value: string) => void;
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
@@ -80,6 +109,7 @@ type UseConversationComposerControllerOptions = {
requestedAt?: string,
options?: {
requestId?: string;
requestOrigin?: 'composer' | 'prompt';
mode?: 'queue' | 'direct';
queueSize?: number;
jobMessage?: string | null;
@@ -95,6 +125,7 @@ type UseConversationComposerControllerOptions = {
previous: ChatComposerAttachment[],
next: ChatComposerAttachment[],
) => ChatComposerAttachment[];
ensureSessionReady?: (sessionId: string) => Promise<boolean>;
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
scrollViewportToBottom: () => void;
};
@@ -104,6 +135,14 @@ type SendMessageOptions = {
draftText?: string;
};
function createClientRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `client-${crypto.randomUUID()}`;
}
return `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function useConversationComposerController({
activeSessionId,
appConfigChat,
@@ -115,6 +154,7 @@ export function useConversationComposerController({
composerRef,
messagesRef,
pendingRequestsRef,
promptRequestIdsRef,
shouldStickToBottomRef,
setDraft,
setComposerAttachments,
@@ -133,11 +173,13 @@ export function useConversationComposerController({
buildOutgoingMessageText,
summarizeRecentContext,
mergeComposerAttachments,
ensureSessionReady,
sendChatRequest,
scrollViewportToBottom,
}: UseConversationComposerControllerOptions) {
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
const activeComposerUploadCountRef = useRef(0);
const latestComposerUploadAttemptByKeyRef = useRef(new Map<string, string>());
const handleComposerFilesPicked = useCallback(
async (files: File[]): Promise<ComposerFilePickResult> => {
@@ -145,6 +187,13 @@ export function useConversationComposerController({
return { items: [] };
}
const batchAttemptId = `composer-upload-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const fileKeys = files.map((file) => buildComposerFilePickKey(file));
fileKeys.forEach((key) => {
latestComposerUploadAttemptByKeyRef.current.set(key, batchAttemptId);
});
const uploadBatch = async (): Promise<ComposerFilePickResult> => {
activeComposerUploadCountRef.current += 1;
@@ -160,6 +209,12 @@ export function useConversationComposerController({
const failedItems: Array<{ fileName: string; reason: string }> = [];
uploadResults.forEach((result, index) => {
const fileKey = fileKeys[index];
if (!fileKey || latestComposerUploadAttemptByKeyRef.current.get(fileKey) !== batchAttemptId) {
return;
}
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
@@ -218,6 +273,7 @@ export function useConversationComposerController({
activeSessionId,
composerUploadQueueRef,
createLocalMessage,
latestComposerUploadAttemptByKeyRef,
mergeComposerAttachments,
setComposerAttachments,
setIsComposerAttachmentUploading,
@@ -235,22 +291,60 @@ export function useConversationComposerController({
}, [composerRef, scrollViewportToBottom]);
const executeSendMessage = useCallback(
(request: PendingContextConfirm) => {
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, omitPromptHistory } = request;
const requestId = `client-${Date.now().toString(36)}`;
async (request: PendingContextConfirm) => {
const {
mode,
text,
origin,
parentRequestId,
chatTypeId,
chatTypeLabel,
chatTypeDescription,
chatTypeBaseDescription,
defaultContextIds,
defaultContexts,
customContextTitle,
customContextContent,
omitPromptHistory,
} = request;
if (ensureSessionReady) {
setActiveSystemStatus('새 채팅방 준비 중...');
setIsSystemStatusPending(true);
const isSessionReady = await ensureSessionReady(activeSessionId);
if (!isSessionReady) {
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
return false;
}
}
const requestId = createClientRequestId();
const outgoingRequest: PendingChatRequest = {
sessionId: activeSessionId,
requestId,
text,
mode,
origin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
omitPromptHistory: omitPromptHistory === true,
chatTypeId,
chatTypeLabel,
chatTypeDescription,
chatTypeBaseDescription,
defaultContextIds,
defaultContexts,
customContextTitle,
customContextContent,
retryCount: 0,
failed: false,
};
if (origin === 'prompt') {
promptRequestIdsRef?.current.add(requestId);
}
if (mode === 'queue') {
const queuedAt = new Date().toISOString();
const optimisticUserMessage: ChatMessage = {
@@ -260,11 +354,13 @@ export function useConversationComposerController({
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 요청을 접수했습니다.',
'# 진행: 대기열 등록을 준비하고 있습니다.',
'# 진행: 순서를 기다리기 전에 요청 내용을 정리하고 있습니다.',
]);
upsertRequestItem({
sessionId: activeSessionId,
requestId,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
status: 'queued',
statusMessage: '대기열 등록',
userMessageId: optimisticUserMessage.id,
@@ -280,6 +376,7 @@ export function useConversationComposerController({
});
syncConversationPreviewForRequest(activeSessionId, text, queuedAt, {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'queue',
queueSize: 1,
jobMessage: '대기열 등록 중',
@@ -301,11 +398,13 @@ export function useConversationComposerController({
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 즉시 요청을 접수했습니다.',
'# 진행: 즉시 실행 대기 중입니다.',
'# 진행: 요청 내용을 정리한 뒤 답변을 준비합니다.',
]);
upsertRequestItem({
sessionId: activeSessionId,
requestId,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
status: 'accepted',
statusMessage: '요청을 접수했습니다.',
userMessageId: optimisticUserMessage.id,
@@ -321,6 +420,7 @@ export function useConversationComposerController({
});
syncConversationPreviewForRequest(activeSessionId, text, new Date().toISOString(), {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'direct',
queueSize: 0,
jobMessage: '즉시 요청 실행 대기 중',
@@ -367,13 +467,16 @@ export function useConversationComposerController({
setDraft('');
setComposerAttachments([]);
focusComposerAfterSend();
return true;
},
[
activeSessionId,
createActivityLogPlaceholder,
createChatMessage,
ensureSessionReady,
focusComposerAfterSend,
pendingRequestsRef,
promptRequestIdsRef,
sendChatRequest,
setActiveSystemStatus,
setComposerAttachments,
@@ -422,6 +525,11 @@ export function useConversationComposerController({
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeBaseDescription: selectedChatType.baseDescription,
defaultContextIds: selectedChatType.defaultContextIds,
defaultContexts: selectedChatType.defaultContexts,
customContextTitle: selectedChatType.customContextTitle,
customContextContent: selectedChatType.customContextContent,
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
});
@@ -434,6 +542,11 @@ export function useConversationComposerController({
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeBaseDescription: selectedChatType.baseDescription,
defaultContextIds: selectedChatType.defaultContextIds,
defaultContexts: selectedChatType.defaultContexts,
customContextTitle: selectedChatType.customContextTitle,
customContextContent: selectedChatType.customContextContent,
includedContextCount: 0,
omittedContextCount: 0,
});

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { emitChatConversationsUpdated } from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
import { mergeConversationItemsPreservingRequestedSession } from './conversationListMerge';
type UseConversationListDataOptions = {
requestedSessionId: string;
@@ -18,94 +18,6 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<SetStateAction<string>>;
};
function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[],
requestedSessionId: string,
) {
const previousBySessionId = new Map(previousItems.map((item) => [item.sessionId, item] as const));
const normalizedNextItems = nextItems.map((item) => {
const previousItem = previousBySessionId.get(item.sessionId);
if (!previousItem) {
return item;
}
const chatTypeId = item.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null;
const lastChatTypeId =
item.lastChatTypeId?.trim() ||
chatTypeId ||
previousItem.lastChatTypeId?.trim() ||
previousItem.chatTypeId?.trim() ||
null;
return {
...item,
chatTypeId,
lastChatTypeId,
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
contextLabel: item.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
contextDescription: item.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
currentRequestId:
item.currentRequestId?.trim() ||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentRequestId : null) ||
null,
currentJobStatus:
item.currentJobStatus ??
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
? previousItem.currentJobStatus
: null),
currentJobMessage:
item.currentJobMessage?.trim() ||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentJobMessage?.trim() : '') ||
null,
currentQueueSize:
item.currentQueueSize > 0
? item.currentQueueSize
: item.currentJobStatus === 'queued'
? Math.max(1, previousItem.currentQueueSize)
: previousItem.currentJobStatus === 'queued' && item.currentJobStatus == null
? Math.max(1, previousItem.currentQueueSize)
: item.currentQueueSize,
currentStatusUpdatedAt:
item.currentStatusUpdatedAt ||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
? previousItem.currentStatusUpdatedAt
: null),
};
});
const normalizedRequestedSessionId = requestedSessionId.trim();
const nextSessionIds = new Set(normalizedNextItems.map((item) => item.sessionId));
const preservedTransientItems = previousItems.filter((item) => {
if (!item.sessionId || nextSessionIds.has(item.sessionId)) {
return false;
}
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
});
if (!normalizedRequestedSessionId) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const hasRequestedSession = normalizedNextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
if (hasRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const preservedRequestedSession =
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
if (!preservedRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
return sortChatConversationSummaries([preservedRequestedSession, ...preservedTransientItems, ...normalizedNextItems]);
}
export function useConversationListData({
requestedSessionId,
enabled = true,
@@ -139,7 +51,6 @@ export function useConversationListData({
setConversationItems((previous) => {
const nextItems = mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId);
emitChatConversationsUpdated(nextItems);
return nextItems;
});
} catch {
@@ -177,6 +88,10 @@ export function useConversationListData({
};
}, [enabled, loadConversationItems]);
useEffect(() => {
emitChatConversationsUpdated(conversationItems);
}, [conversationItems]);
return {
conversationItems,
setConversationItems,

View File

@@ -18,6 +18,14 @@ type PendingChatRequest = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeBaseDescription?: string;
defaultContexts?: Array<{
id: string;
title: string;
content: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
retryCount: number;
failed: boolean;
};
@@ -281,7 +289,18 @@ export function useConversationRoomActionsController({
setIsEditingConversationTitle(false);
try {
const item = await chatGateway.renameConversation(sessionId, trimmedTitle);
const item = activeConversation.isDraftOnly
? await chatGateway.createConversation({
sessionId,
title: trimmedTitle,
chatTypeId: activeConversation.chatTypeId,
lastChatTypeId: activeConversation.lastChatTypeId,
generalSectionName: activeConversation.generalSectionName,
contextLabel: activeConversation.contextLabel ?? undefined,
contextDescription: activeConversation.contextDescription ?? undefined,
notifyOffline: activeConversation.notifyOffline,
})
: await chatGateway.renameConversation(sessionId, trimmedTitle);
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
setEditingConversationTitle(item.title);
} catch (error) {
@@ -306,7 +325,16 @@ export function useConversationRoomActionsController({
const handleDeleteConversation = useCallback(
async (sessionId: string) => {
try {
await chatGateway.deleteConversation(sessionId);
const targetConversation = conversationItems.find((entry) => entry.sessionId === sessionId) ?? null;
if (!targetConversation) {
return;
}
if (!targetConversation.isDraftOnly) {
await chatGateway.deleteConversation(sessionId);
}
const remaining = conversationItems.filter((entry) => entry.sessionId !== sessionId);
sessionMessageCacheRef.current.delete(sessionId);
setConversationItems(remaining);

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
import { mergeConversationRequestStatusMessage, mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import { chatGateway } from '../data/chatGateway';
import type {
@@ -29,10 +29,12 @@ function mergeConversationRequests(
const nextUserText = item.userText.trim() || previousItem.userText.trim();
const nextResponseText = item.responseText.trim() || previousItem.responseText.trim();
const nextStatusMessage = item.statusMessage?.trim() || previousItem.statusMessage?.trim() || null;
const nextStatusMessage = mergeConversationRequestStatusMessage(previousItem, item);
return {
...item,
requestOrigin: item.requestOrigin ?? previousItem.requestOrigin ?? null,
parentRequestId: item.parentRequestId?.trim() || previousItem.parentRequestId?.trim() || null,
statusMessage: nextStatusMessage,
userMessageId: item.userMessageId ?? previousItem.userMessageId,
userText: nextUserText,
@@ -72,8 +74,17 @@ type UseConversationRoomDataOptions = {
setIsLoadingOlderMessages: Dispatch<SetStateAction<boolean>>;
queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void;
viewportRef: MutableRefObject<HTMLDivElement | null>;
onMissingConversation?: (sessionId: string) => void;
};
function isMissingConversationError(error: unknown) {
if (!(error instanceof Error)) {
return false;
}
return /채팅방을 찾을 수 없습니다|404\b|not found/i.test(error.message);
}
export function useConversationRoomData({
activeSessionId,
activeConversationIsDraftOnly = false,
@@ -98,6 +109,7 @@ export function useConversationRoomData({
setIsLoadingOlderMessages,
queueViewportPrependRestore,
viewportRef,
onMissingConversation,
}: UseConversationRoomDataOptions) {
const previousSessionIdRef = useRef('');
@@ -209,8 +221,20 @@ export function useConversationRoomData({
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
}
} catch {
} catch (error) {
if (!isCancelled) {
if (cachedMessages.length === 0 && isMissingConversationError(error)) {
sessionMessageCacheRef.current.delete(requestedSessionId);
setConversationItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId));
setMessages([]);
setRequestItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId));
setHasOlderMessages(false);
setOldestLoadedMessageId(null);
setConversationLoadingLabel('삭제되었거나 만료된 채팅방입니다.');
onMissingConversation?.(requestedSessionId);
return;
}
setMessages(cachedMessages);
setHasOlderMessages(false);
setOldestLoadedMessageId(cachedMessages[0]?.id ?? null);
@@ -250,6 +274,7 @@ export function useConversationRoomData({
setHasOlderMessages,
setMessages,
setOldestLoadedMessageId,
onMissingConversation,
setRequestItems,
]);

View File

@@ -337,6 +337,36 @@ export function useConversationViewportController({
resetPullToLoad,
]);
useEffect(() => {
const handleViewportInteractionReset = () => {
resetPullToLoad();
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
resetPullToLoad();
return;
}
window.requestAnimationFrame(() => {
resetPullToLoad();
handleViewportScroll();
});
};
window.addEventListener('resize', handleViewportInteractionReset);
window.addEventListener('pageshow', handleViewportInteractionReset);
window.addEventListener('blur', handleViewportInteractionReset);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('resize', handleViewportInteractionReset);
window.removeEventListener('pageshow', handleViewportInteractionReset);
window.removeEventListener('blur', handleViewportInteractionReset);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [handleViewportScroll, resetPullToLoad]);
useEffect(() => {
if (connectionState === 'disconnected') {
clearSystemStatusTimer();

View File

@@ -9,6 +9,8 @@ import {
} from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
const UNREAD_COUNT_REFRESH_INTERVAL_MS = 15_000;
type UseUnreadCountsResult = {
chatUnreadCount: number;
notificationUnreadCount: number;
@@ -134,6 +136,40 @@ export function useUnreadCounts(): UseUnreadCountsResult {
};
}, []);
useEffect(() => {
const refreshAllUnreadCounts = () => {
void refreshChatUnreadCount();
void refreshNotificationUnreadCount();
};
const handleVisibilityOrFocus = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
refreshAllUnreadCounts();
};
const intervalId = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
refreshAllUnreadCounts();
}, UNREAD_COUNT_REFRESH_INTERVAL_MS);
window.addEventListener('focus', handleVisibilityOrFocus);
window.addEventListener('pageshow', handleVisibilityOrFocus);
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
return () => {
window.clearInterval(intervalId);
window.removeEventListener('focus', handleVisibilityOrFocus);
window.removeEventListener('pageshow', handleVisibilityOrFocus);
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
};
}, []);
return {
chatUnreadCount,
notificationUnreadCount,

56
src/app/main/clientIdentity.ts Executable file → Normal file
View File

@@ -1,6 +1,28 @@
import type { AppPageDescriptor } from '../../store/appStore/types';
import { isPreviewRuntime } from './previewRuntime';
export const CLIENT_ID_STORAGE_KEY = 'work-app.visitor.client-id';
const PREVIEW_CLIENT_ID_STORAGE_KEY = 'work-app.preview-runtime.client-id';
function getClientStorage() {
if (typeof window === 'undefined') {
return null;
}
if (isPreviewRuntime()) {
return {
key: PREVIEW_CLIENT_ID_STORAGE_KEY,
primaryStorage: window.localStorage,
legacyStorage: window.sessionStorage,
};
}
return {
key: CLIENT_ID_STORAGE_KEY,
primaryStorage: window.localStorage,
legacyStorage: null,
};
}
function generateFallbackClientId() {
return `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
@@ -15,19 +37,38 @@ function generateClientId() {
}
export function getClientId() {
if (typeof window === 'undefined') {
const storageConfig = getClientStorage();
if (!storageConfig) {
return '';
}
return window.localStorage.getItem(CLIENT_ID_STORAGE_KEY)?.trim() ?? '';
const savedClientId = storageConfig.primaryStorage.getItem(storageConfig.key)?.trim() ?? '';
if (savedClientId) {
return savedClientId;
}
const legacyClientId = storageConfig.legacyStorage?.getItem(storageConfig.key)?.trim() ?? '';
if (legacyClientId) {
storageConfig.primaryStorage.setItem(storageConfig.key, legacyClientId);
storageConfig.legacyStorage?.removeItem(storageConfig.key);
return legacyClientId;
}
return '';
}
export function clearClientId() {
if (typeof window === 'undefined') {
const storageConfig = getClientStorage();
if (!storageConfig) {
return;
}
window.localStorage.removeItem(CLIENT_ID_STORAGE_KEY);
storageConfig.primaryStorage.removeItem(storageConfig.key);
storageConfig.legacyStorage?.removeItem(storageConfig.key);
}
export function getOrCreateClientId() {
@@ -37,12 +78,15 @@ export function getOrCreateClientId() {
return existingClientId;
}
if (typeof window === 'undefined') {
const storageConfig = getClientStorage();
if (!storageConfig) {
return '';
}
const nextClientId = generateClientId();
window.localStorage.setItem(CLIENT_ID_STORAGE_KEY, nextClientId);
storageConfig.primaryStorage.setItem(storageConfig.key, nextClientId);
storageConfig.legacyStorage?.removeItem(storageConfig.key);
return nextClientId;
}

0
src/app/main/errorLogApi.ts Executable file → Normal file
View File

0
src/app/main/index.ts Executable file → Normal file
View File

73
src/app/main/layout/MainLayout.tsx Executable file → Normal file
View File

@@ -11,6 +11,7 @@ import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/ut
import { MainContent } from '../MainContent';
import { MainHeader } from '../MainHeader';
import { MainSidebar } from '../MainSidebar';
import { appendPreviewRuntimeSearch, isPreviewRuntime, readPreviewRuntimeDeviceModeFromUrl } from '../previewRuntime';
import { buildSearchOptions } from './buildSearchOptions';
import { MainLayoutContextProvider } from './MainLayoutContext';
import { useMainLayoutData } from './useMainLayoutData';
@@ -161,6 +162,10 @@ function getIsMobileViewport() {
return false;
}
if (readPreviewRuntimeDeviceModeFromUrl() === 'mobile') {
return true;
}
return window.matchMedia('(max-width: 768px)').matches;
}
@@ -169,10 +174,14 @@ function getIsSidebarOverlayViewport(topMenu: TopMenuKey) {
return false;
}
if (readPreviewRuntimeDeviceModeFromUrl() === 'mobile') {
return true;
}
return window.matchMedia(topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)').matches;
}
function resolveSidebarCollapsedForViewport(isSidebarOverlayViewport: boolean, topMenu: TopMenuKey) {
function resolveSidebarCollapsedForViewport(isSidebarOverlayViewport: boolean) {
if (!isSidebarOverlayViewport) {
return false;
}
@@ -214,6 +223,7 @@ function resolveSidebarOpenKeys(
}
export function MainLayout() {
const previewRuntime = isPreviewRuntime();
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -225,7 +235,7 @@ export function MainLayout() {
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu), routeState.topMenu),
resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu)),
);
const [isSidebarOverlayViewport, setIsSidebarOverlayViewport] = useState(() =>
getIsSidebarOverlayViewport(routeState.topMenu),
@@ -240,6 +250,10 @@ export function MainLayout() {
const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0);
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, savedLayoutsReady, setSavedLayouts, docFolders } = layoutData;
const { chatUnreadCount } = useUnreadCounts();
const navigateWithinApp = (path: string, options?: { replace?: boolean }) => {
const nextPath = previewRuntime ? appendPreviewRuntimeSearch(path, location.search) : path;
navigate(nextPath, options);
};
useEffect(() => {
void syncAppConfigFromServer();
@@ -248,7 +262,7 @@ export function MainLayout() {
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 768px)');
const updateViewport = () => {
setIsMobileViewport(mediaQuery.matches);
setIsMobileViewport(getIsMobileViewport());
};
updateViewport();
@@ -262,7 +276,7 @@ export function MainLayout() {
useEffect(() => {
const mediaQuery = window.matchMedia(routeState.topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)');
const updateViewport = () => {
setIsSidebarOverlayViewport(mediaQuery.matches);
setIsSidebarOverlayViewport(getIsSidebarOverlayViewport(routeState.topMenu));
};
updateViewport();
@@ -274,7 +288,7 @@ export function MainLayout() {
}, [routeState.topMenu]);
useEffect(() => {
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isSidebarOverlayViewport, routeState.topMenu));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isSidebarOverlayViewport));
}, [isSidebarOverlayViewport, routeState.topMenu]);
useEffect(() => {
@@ -283,17 +297,17 @@ export function MainLayout() {
useEffect(() => {
if (docFolders.length > 0 && routeState.topMenu === 'docs' && !docFolders.includes(routeState.docsMenu)) {
navigate(buildDocsPath(docFolders[0]), { replace: true });
navigateWithinApp(buildDocsPath(docFolders[0]), { replace: true });
}
}, [docFolders, navigate, routeState.docsMenu, routeState.topMenu]);
}, [docFolders, navigate, location.search, previewRuntime, routeState.docsMenu, routeState.topMenu]);
useEffect(() => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu);
if (savedLayoutId && savedLayoutsReady && !savedLayouts.some((record) => record.id === savedLayoutId)) {
navigate(buildPlayPath('layout'), { replace: true });
navigateWithinApp(buildPlayPath('layout'), { replace: true });
}
}, [navigate, routeState.playMenu, savedLayouts, savedLayoutsReady]);
}, [location.search, navigate, previewRuntime, routeState.playMenu, savedLayouts, savedLayoutsReady]);
useEffect(() => {
if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) {
@@ -384,9 +398,10 @@ export function MainLayout() {
widgetSamples,
docFolders,
docsDocuments,
savedLayouts,
hasAccess,
navigateTo: (path) => {
navigate(path);
navigateWithinApp(path);
},
setFocusedComponentId,
requestPlanQuickFilter: (filter) => {
@@ -394,7 +409,18 @@ export function MainLayout() {
setPlanQuickFilterRequestKey((previous) => previous + 1);
},
}),
[componentSamples, docFolders, docsDocuments, hasAccess, navigate, setFocusedComponentId, widgetSamples],
[
componentSamples,
docFolders,
docsDocuments,
hasAccess,
location.search,
navigate,
previewRuntime,
savedLayouts,
setFocusedComponentId,
widgetSamples,
],
);
useEffect(() => {
@@ -450,8 +476,8 @@ export function MainLayout() {
searchOptions,
}}
>
<Layout className="app-shell app-shell--docs-api">
<ChatRuntimeBridgeV2 />
<Layout className={`app-shell app-shell--docs-api${previewRuntime ? ' app-shell--preview-runtime' : ''}`}>
{routeState.topMenu === 'chat' ? null : <ChatRuntimeBridgeV2 />}
{contentExpanded ? null : (
<MainHeader
activeTopMenu={routeState.topMenu}
@@ -465,15 +491,15 @@ export function MainLayout() {
setContentExpanded((previous) => !previous);
}}
onChangeTopMenu={(menu) => {
navigate(resolveTopMenuPath(menu, currentDocsFolder));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu), menu));
navigateWithinApp(resolveTopMenuPath(menu, currentDocsFolder));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu)));
}}
onOpenPlanQuickFilter={(filter) => {
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
setActivePlanQuickFilter(filter);
setPlanQuickFilterRequestKey((previous) => previous + 1);
navigate(buildPlansPath(targetPlanMenu));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport('plans'), 'plans'));
navigateWithinApp(buildPlansPath(targetPlanMenu));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport('plans')));
scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all');
}}
/>
@@ -499,13 +525,13 @@ export function MainLayout() {
selectedPlayMenu={routeState.playMenu}
onOpenKeysChange={setSidebarOpenKeys}
onSelectApiMenu={(key) => {
navigate(buildApisPath(key as ApiSectionKey));
navigateWithinApp(buildApisPath(key as ApiSectionKey));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectDocsMenu={(key) => {
navigate(buildDocsPath(key));
navigateWithinApp(buildDocsPath(key));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
@@ -513,20 +539,20 @@ export function MainLayout() {
onSelectPlanMenu={(key) => {
setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null);
setPlanQuickFilterRequestKey((previous) => previous + 1);
navigate(buildPlansPath(key));
navigateWithinApp(buildPlansPath(key));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectChatMenu={(key) => {
navigate(buildChatPath(key));
navigateWithinApp(buildChatPath(key));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectPlayMenu={(key) => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout' | 'test'));
navigateWithinApp(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout' | 'test'));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
@@ -539,7 +565,8 @@ export function MainLayout() {
<MainContent
contentExpanded={contentExpanded}
sidebarOverlayActive={isSidebarOverlayViewport && !sidebarCollapsed}
sidebarOverlayActive={!previewRuntime && isSidebarOverlayViewport && !sidebarCollapsed}
disableWindowLayer={previewRuntime}
onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}
>
<Outlet />

0
src/app/main/layout/MainLayoutContext.ts Executable file → Normal file
View File

86
src/app/main/layout/buildSearchOptions.ts Executable file → Normal file
View File

@@ -5,6 +5,8 @@ import {
buildChatPath,
buildDocsPath,
buildPlansPath,
buildPlayPath,
buildSavedLayoutPath,
getDocsSectionLabel,
PLAN_FILTER_LABELS,
PLAN_GROUP_LABEL,
@@ -22,6 +24,10 @@ type BuildSearchOptionsParams = {
folder: string;
preview?: string;
}>;
savedLayouts: Array<{
id: string;
name: string;
}>;
hasAccess: boolean;
navigateTo: (path: string) => void;
setFocusedComponentId: (value: string | null) => void;
@@ -33,6 +39,7 @@ export function buildSearchOptions({
widgetSamples,
docFolders,
docsDocuments,
savedLayouts,
hasAccess,
navigateTo,
setFocusedComponentId,
@@ -183,6 +190,19 @@ export function buildSearchOptions({
} satisfies SearchKeywordOption,
]
: []),
{
id: 'page:preview:app',
label: 'Preview App / 모바일 앱 열기',
group: 'Page',
keywords: ['preview', 'iframe', '미리보기', 'preview app', '프리뷰 앱', '모바일 앱', 'cbt app'],
description: 'preview.sm-home.cloud에서 실제 앱 컨테이너 화면을 모바일 해상도로 엽니다.',
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildPlayPath('cbt'));
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:live',
label: 'Codex Live / Codex Live',
@@ -209,7 +229,7 @@ export function buildSearchOptions({
},
{
id: 'page:chat:resources',
label: 'Codex Live / 리소스 관리',
label: '리소스 관리 / 리소스 관리',
group: 'Page',
keywords: ['codex live', 'resource', 'resources', 'file', 'files', '리소스', '파일', '파일 시스템'],
onSelect: () => {
@@ -231,34 +251,30 @@ export function buildSearchOptions({
},
onSelectWindow,
},
...(hasAccess
? [
{
id: 'page:chat:manage',
label: '채팅 관리 / 유형 권한 관리',
group: 'Page',
keywords: ['chat manage', 'chat type', 'permission', '권한', '채팅 유형', '채팅 관리'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('manage'));
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:manage-defaults',
label: '채팅 관리 / 기본 유형 관리',
group: 'Page',
keywords: ['chat manage', 'default type', 'default context', '기본 유형', '기본 context', '채팅 관리'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('manage-defaults'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
]
: []),
{
id: 'page:chat:manage',
label: '채팅 관리 / 유형 권한 관리',
group: 'Page',
keywords: ['chat manage', 'chat type', 'permission', '권한', '채팅 유형', '채팅 관리'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('manage'));
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:manage-defaults',
label: '채팅 관리 / 공통 문맥 관리',
group: 'Page',
keywords: ['chat manage', 'default type', 'default context', '기본 유형', '공통 문맥', '기본 context', '채팅 관리'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('manage-defaults'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
...docFolders.map((folder) => ({
id: `docs-folder:${folder}`,
label: `Docs / ${getDocsSectionLabel(folder)}`,
@@ -285,6 +301,18 @@ export function buildSearchOptions({
},
onSelectWindow,
})),
...savedLayouts.map((layout) => ({
id: `page:play:layout-record:${layout.id}`,
label: `Play / ${layout.name}`,
group: 'Play Layout',
keywords: compactKeywords([layout.name, layout.id, 'play', 'layout', 'saved layout', '저장 레이아웃', '메모']),
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildSavedLayoutPath(layout.id));
setFocusedComponentId(null);
},
onSelectWindow,
})),
...componentSamples.map((entry) => ({
id: `component:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`,
label: entry.sampleMeta.title,

0
src/app/main/layout/useMainLayoutData.ts Executable file → Normal file
View File

View File

@@ -5,25 +5,49 @@ import {
LoadingOutlined,
MinusCircleOutlined,
} from '@ant-design/icons';
import { GENERAL_REQUEST_CHAT_TYPE_ID } from '../chatTypeDefaults';
import type { ChatConversationRequest } from './types';
type ActivityChecklistState = 'complete' | 'current' | 'pending' | 'error';
type ActivityChecklistStageKey = 'intake' | 'analysis' | 'inspection' | 'execution' | 'result';
export type ActivityChecklistState = 'complete' | 'current' | 'pending' | 'error';
export type ActivityChecklistStageKey = 'intake' | 'analysis' | 'inspection' | 'confirmation' | 'execution' | 'result';
type ActivityChecklistEntry = {
export type ActivityChecklistEntry = {
key: string;
label: string;
state: ActivityChecklistState;
note: string;
};
const CHECKLIST_STAGE_ORDER: ActivityChecklistStageKey[] = ['intake', 'analysis', 'inspection', 'execution', 'result'];
function getEntryStateLabel(entry: ActivityChecklistEntry) {
if (entry.state === 'complete') {
if (entry.key === 'confirmation') {
return '확인 완료';
}
if (entry.key === 'execution' || entry.key === 'result') {
return '회신 완료';
}
return '완료';
}
if (entry.state === 'current') {
return '진행중';
}
if (entry.state === 'error') {
return '확인필요';
}
return '대기';
}
const CHECKLIST_STAGE_ORDER: ActivityChecklistStageKey[] = ['intake', 'analysis', 'inspection', 'confirmation', 'execution', 'result'];
const CHECKLIST_STAGE_LABELS: Record<ActivityChecklistStageKey, string> = {
intake: '요청 접수',
analysis: '요청 분석',
inspection: '관련 확인',
confirmation: '확인',
execution: '구현·응답 작성',
result: '검증·결과 정리',
};
@@ -47,6 +71,7 @@ const CHECKLIST_STAGE_PATTERNS: Record<ActivityChecklistStageKey, RegExp[]> = {
/리소스/i,
/화면/i,
],
confirmation: [/내부 상태 확인/i, /반영 확인/i, /최종 확인/i, /동작 확인/i, /확인 단계/i, /확인합니다/i],
execution: [/구현/i, /수정/i, /변경/i, /작성/i, /빌드/i, /patch/i, /diff/i, /실시간으로 전송 중/i],
result: [/검증/i, /테스트/i, /캡처/i, /preview/i, /스크린샷/i, /완료/i, /결과/i, /정리/i],
};
@@ -138,7 +163,7 @@ function resolveCurrentStageKey(lines: string[], request?: ChatConversationReque
continue;
}
for (const stageKey of ['result', 'execution', 'inspection', 'analysis', 'intake'] as const) {
for (const stageKey of ['result', 'execution', 'confirmation', 'inspection', 'analysis', 'intake'] as const) {
if (CHECKLIST_STAGE_PATTERNS[stageKey].some((pattern) => pattern.test(candidate))) {
return stageKey;
}
@@ -174,7 +199,7 @@ function resolveResultNote(request?: ChatConversationRequest) {
return normalizedStatusMessage || '최종 결과를 정리하는 단계입니다.';
}
function buildChecklistEntries(lines: string[], request?: ChatConversationRequest) {
export function buildChatActivityChecklistEntries(lines: string[], request?: ChatConversationRequest) {
const currentStageKey = resolveCurrentStageKey(lines, request);
const currentStageIndex = CHECKLIST_STAGE_ORDER.indexOf(currentStageKey);
const isTerminalComplete = request?.status === 'completed';
@@ -208,6 +233,9 @@ function buildChecklistEntries(lines: string[], request?: ChatConversationReques
case 'inspection':
note = observationSummary ? `${observationSummary} 기준으로 확인합니다.` : 'DB, API, 소스, 화면 중 필요한 대상을 확인합니다.';
break;
case 'confirmation':
note = request?.hasResponse ? '내부 상태와 반영 내용을 한 번 더 확인합니다.' : '현재 상태와 확인 포인트를 정리합니다.';
break;
case 'execution':
note = request?.hasResponse ? '응답 초안 또는 변경 결과를 작성 중입니다.' : '필요한 구현과 응답 작성을 진행합니다.';
break;
@@ -267,18 +295,16 @@ function buildSummaryLabel(entries: ActivityChecklistEntry[]) {
export function ChatActivityChecklist({
lines,
request,
chatTypeId,
}: {
lines: string[];
request?: ChatConversationRequest;
chatTypeId?: string | null;
}) {
if ((chatTypeId ?? '').trim() !== GENERAL_REQUEST_CHAT_TYPE_ID) {
if (lines.length === 0 && !request) {
return null;
}
const normalizedLines = normalizeLines(lines);
const entries = buildChecklistEntries(normalizedLines, request);
const entries = buildChatActivityChecklistEntries(normalizedLines, request);
return (
<section className="app-chat-activity-checklist" aria-label="Plan 체크리스트">
@@ -303,15 +329,7 @@ export function ChatActivityChecklist({
<div className="app-chat-activity-checklist__content">
<div className="app-chat-activity-checklist__row">
<span className="app-chat-activity-checklist__label">{entry.label}</span>
<span className="app-chat-activity-checklist__state">
{entry.state === 'complete'
? '완료'
: entry.state === 'current'
? '진행중'
: entry.state === 'error'
? '확인필요'
: '대기'}
</span>
<span className="app-chat-activity-checklist__state">{getEntryStateLabel(entry)}</span>
</div>
<p className="app-chat-activity-checklist__note">{entry.note}</p>
</div>

1233
src/app/main/mainChatPanel/ChatConversationView.tsx Executable file → Normal file

File diff suppressed because it is too large Load Diff

35
src/app/main/mainChatPanel/ChatPreviewBody.tsx Executable file → Normal file
View File

@@ -12,15 +12,16 @@ import {
import { Alert, Button, Empty, Space, Spin, Typography, message } from 'antd';
import { InlineImage } from '../../../components/common/InlineImage';
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
import { CodexDiffBlock } from '../../../components/previewer';
import { CodexDiffBlock, ZoomablePreviewSurface } from '../../../components/previewer';
import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers';
import { ChatDataTablePreview, resolveTabularPreviewModel } from './ChatDataTablePreview';
import { triggerResourceDownload } from './downloadUtils';
import type { PreviewKind } from './previewKind';
import '../../../components/previewer/PreviewerUI.css';
const { Paragraph, Text } = Typography;
export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
export type ChatPreviewKind = PreviewKind;
export type ChatPreviewTarget = {
label: string;
@@ -243,6 +244,7 @@ type ChatPreviewBodyProps = {
previewContentType?: string;
maxMarkdownBlocks?: number;
renderHtmlAsFrame?: boolean;
fullscreen?: boolean;
};
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
@@ -272,6 +274,7 @@ export function ChatPreviewBody({
previewContentType,
maxMarkdownBlocks,
renderHtmlAsFrame = false,
fullscreen = false,
}: ChatPreviewBodyProps) {
if (!target) {
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
@@ -309,7 +312,7 @@ export function ChatPreviewBody({
}
if (target.kind === 'image') {
return (
const imageNode = (
<InlineImage
src={target.url}
alt={target.label}
@@ -317,6 +320,18 @@ export function ChatPreviewBody({
fallbackText="이미지 preview를 불러오지 못했습니다."
/>
);
if (fullscreen) {
return (
<ZoomablePreviewSurface stageClassName="app-chat-panel__preview-zoom-stage" contentClassName="app-chat-panel__preview-zoom-content">
{imageNode}
</ZoomablePreviewSurface>
);
}
return (
imageNode
);
}
if (target.kind === 'video') {
@@ -369,13 +384,25 @@ export function ChatPreviewBody({
const resolvedLanguage = resolveCodeLanguage(target, previewText);
if (renderHtmlAsFrame && resolvedLanguage === 'html' && canRenderFramePreview(target.url)) {
return (
const frameNode = (
<iframe
title={target.label}
srcDoc={buildHtmlFrameDocument(previewText, target.url)}
className="app-chat-panel__preview-frame"
/>
);
if (fullscreen) {
return (
<ZoomablePreviewSurface stageClassName="app-chat-panel__preview-zoom-stage" contentClassName="app-chat-panel__preview-zoom-content">
{frameNode}
</ZoomablePreviewSurface>
);
}
return (
frameNode
);
}
if (target.kind === 'diff' || resolvedLanguage === 'diff') {

View File

@@ -1,6 +1,8 @@
import {
CodeOutlined,
CheckCircleOutlined,
DownOutlined,
EyeOutlined,
ExpandOutlined,
LeftOutlined,
LinkOutlined,
@@ -9,12 +11,18 @@ import {
RightOutlined,
UpOutlined,
} from '@ant-design/icons';
import { App, Button, Input, Modal, Spin, Typography } from 'antd';
import { App, Button, Input, Spin, Tabs, Typography } from 'antd';
import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { StepperUI } from '../../../components/stepper';
import { InlineImage } from '../../../components/common/InlineImage';
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
import { FullscreenPreviewModal, ZoomablePreviewSurface } from '../../../components/previewer';
import { renderEditorBlock } from '../../../components/previewer/renderers';
import { ChatPreviewBody } from './ChatPreviewBody';
import { isMarkdownContentType, isMarkdownResourceUrl, normalizeChatResourceUrl } from './chatResourceUrl';
import { openChatExternalLink } from './linkNavigation';
import { classifyPreviewKind } from './previewKind';
import { resolvePromptPreviewOptionValue } from './promptPreviewState';
import type { ChatMessagePart } from './types';
const { Paragraph, Text } = Typography;
@@ -104,6 +112,14 @@ function buildStepSelectionText(step: PromptStep, selectedValues: string[]) {
return buildOptionSelectionText(step.options, selectedValues);
}
function getPreviewablePromptOptions(step: PromptStep | null | undefined) {
if (!step) {
return [];
}
return step.options.filter((option) => option.preview);
}
function replacePromptTemplate(
template: string,
replacements: Record<string, string>,
@@ -114,6 +130,38 @@ function replacePromptTemplate(
);
}
function normalizePromptTemplateKey(value: string) {
const normalized = value.trim().replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
return normalized || 'step';
}
function buildStepTemplateReplacements(steps: PromptStep[], stepSelections: PromptStepDraftSelection[]) {
return stepSelections.reduce<Record<string, string>>((replacements, selection, index) => {
const step = steps.find((candidate) => candidate.key === selection.stepKey) ?? null;
const stepKey = normalizePromptTemplateKey(selection.stepKey || step?.key || `step_${index + 1}`);
const stepTitle = selection.stepTitle || step?.title || `단계 ${index + 1}`;
const selectedOptions = step?.options.filter((option) => selection.selectedValues.includes(option.value)) ?? [];
const selectedValues = selection.selectedValues.join(', ');
const selectedLabels = selectedOptions.map((option) => option.label).join(', ');
const selectionText = step ? buildStepSelectionText(step, selection.selectedValues) : selectedValues;
const trimmedFreeText = selection.freeText.trim();
const summaryText = selection.skipped
? `${stepTitle}: 건너뜀`
: `${stepTitle}: ${selectionText || '선택 없음'}${trimmedFreeText ? ` / 추가 요청: ${trimmedFreeText.replace(/\n+/g, ' ')}` : ''}`;
replacements[`${stepKey}_title`] = stepTitle;
replacements[`${stepKey}_value`] = selection.selectedValues[0] ?? '';
replacements[`${stepKey}_values`] = selectedValues;
replacements[`${stepKey}_label`] = selectedOptions[0]?.label ?? '';
replacements[`${stepKey}_labels`] = selectedLabels;
replacements[`${stepKey}_text`] = selectionText;
replacements[`${stepKey}_summary`] = summaryText;
replacements[`${stepKey}_free_text`] = trimmedFreeText;
replacements[`${stepKey}_free_text_block`] = trimmedFreeText ? `추가 요청:\n${trimmedFreeText}` : '';
return replacements;
}, {});
}
function normalizePromptDraftSelection(
target: PromptTarget,
selectionOrSelectedValues: PromptDraftSelection | string[],
@@ -243,7 +291,14 @@ export function buildPromptResponseText(
return `${index + 1}. ${stepTitle}: ${selectionText || '선택 없음'}${freeTextSummary}`;
});
const trimmedFreeText = draft.freeText.trim();
const lastMeaningfulSelection = [...stepSelections].reverse().find(
(selection) => selection.skipped || selection.selectedValues.length > 0 || selection.freeText.trim().length > 0,
);
const lastMeaningfulStep = lastMeaningfulSelection
? steps.find((candidate) => candidate.key === lastMeaningfulSelection.stepKey) ?? null
: null;
const template =
lastMeaningfulStep?.responseTemplate?.trim() ||
target.responseTemplate?.trim() ||
`프롬프트 "${target.title}" 단계 선택을 정리했습니다.\n{{step_summaries}}\n{{custom_text_block}}`;
const resolvedText = replacePromptTemplate(template, {
@@ -257,6 +312,7 @@ export function buildPromptResponseText(
custom_text_block: trimmedFreeText ? `최종 요청:\n${trimmedFreeText}` : '',
step_summaries: stepSummaryLines.join('\n'),
step_count: String(stepSelections.length),
...buildStepTemplateReplacements(steps, stepSelections),
}).trim();
if (!trimmedFreeText || resolvedText.includes(trimmedFreeText)) {
@@ -302,6 +358,26 @@ function isHtmlLikeUrl(url: string) {
}
}
function canShowHtmlPreviewActions(preview: PromptPreview | null | undefined) {
if (!preview) {
return false;
}
if (preview.type === 'html') {
return true;
}
if (preview.type !== 'resource') {
return false;
}
if (preview.content?.trim()) {
return /<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(preview.content);
}
return Boolean(preview.url && isHtmlLikeUrl(preview.url));
}
function canRenderFramePreview(url: string) {
if (typeof window === 'undefined') {
return false;
@@ -385,11 +461,22 @@ function isTextLikeContentType(contentType: string | null) {
);
}
function resolvePromptPreviewUrl(url?: string | null) {
const normalized = String(url ?? '').trim();
if (!normalized) {
return '';
}
return normalizeChatResourceUrl(normalized);
}
function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
const [remoteContent, setRemoteContent] = useState<string | null>(preview?.content ?? null);
const [remoteContentType, setRemoteContentType] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState('');
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview?.url);
useEffect(() => {
setRemoteContent(preview?.content ?? null);
@@ -399,12 +486,12 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
const shouldFetchTextPreview =
preview?.type === 'markdown' || preview?.type === 'html';
const shouldInspectResourcePreview =
preview?.type === 'resource' && preview.url && canRenderFramePreview(preview.url);
preview?.type === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
if (
!preview ||
preview.content ||
!preview.url ||
!normalizedPreviewUrl ||
(!shouldFetchTextPreview && !shouldInspectResourcePreview)
) {
setIsLoading(false);
@@ -414,7 +501,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
const controller = new AbortController();
setIsLoading(true);
void fetch(preview.url, {
void fetch(normalizedPreviewUrl, {
credentials: 'include',
signal: controller.signal,
})
@@ -454,7 +541,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
});
return () => controller.abort();
}, [preview]);
}, [normalizedPreviewUrl, preview]);
return { remoteContent, remoteContentType, isLoading, loadError };
}
@@ -462,23 +549,36 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
function PromptPreviewSurface({
preview,
compact = false,
htmlMode = 'preview',
}: {
preview: PromptPreview;
compact?: boolean;
htmlMode?: 'preview' | 'source';
}) {
const { remoteContent, remoteContentType, isLoading, loadError } = usePromptPreviewContent(preview);
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview.url);
const shouldRenderAsHtml = shouldRenderAsHtmlDocument(preview, remoteContentType, remoteContent);
const htmlDocument = shouldRenderAsHtml ? buildHtmlFrameDocument(remoteContent || '', preview.url) : null;
const htmlDocument = shouldRenderAsHtml ? buildHtmlFrameDocument(remoteContent || '', normalizedPreviewUrl || preview.url) : null;
if (preview.type === 'image' && preview.url) {
return (
if (preview.type === 'image' && normalizedPreviewUrl) {
const imageNode = (
<InlineImage
src={preview.url}
src={normalizedPreviewUrl}
alt={preview.alt?.trim() || preview.title?.trim() || 'prompt preview image'}
className={`app-chat-prompt-card__preview-image${compact ? ' app-chat-prompt-card__preview-image--compact' : ''}`}
fallbackText="이미지 preview를 불러오지 못했습니다."
/>
);
if (compact) {
return imageNode;
}
return (
<ZoomablePreviewSurface stageClassName="app-chat-prompt-card__preview-zoom-stage" contentClassName="app-chat-prompt-card__preview-zoom-content">
{imageNode}
</ZoomablePreviewSurface>
);
}
if (isLoading) {
@@ -504,10 +604,10 @@ function PromptPreviewSurface({
if (preview.type === 'html') {
const htmlContent = remoteContent || '';
if (preview.url && isAppRouteUrl(preview.url)) {
if (normalizedPreviewUrl && isAppRouteUrl(normalizedPreviewUrl)) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
prompt iframe으로 . `/api/chat/resources/...html` HTML URL을 .
prompt iframe으로 . `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` HTML URL을 .
</div>
);
}
@@ -515,26 +615,49 @@ function PromptPreviewSurface({
if (isHtmlFallbackPreview(preview, htmlContent)) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
fallback HTML이 . `type:"html"` HTML , HTML `type:"resource"` `/api/chat/resources/...html` URL로 .
fallback HTML이 . `type:"html"` HTML , `type:"resource"` `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` HTML .
</div>
);
}
return (
if (htmlMode === 'source') {
return (
<div className="app-chat-prompt-card__preview-code">
{renderEditorBlock(htmlContent || '표시할 HTML 코드가 없습니다.', 'html', 'code')}
</div>
);
}
const frameNode = (
<iframe
title={preview.title?.trim() || 'prompt html preview'}
srcDoc={htmlDocument ?? buildHtmlFrameDocument(htmlContent, preview.url)}
srcDoc={htmlDocument ?? buildHtmlFrameDocument(htmlContent, normalizedPreviewUrl || preview.url)}
className="app-chat-prompt-card__preview-frame"
sandbox="allow-same-origin"
/>
);
if (compact) {
return frameNode;
}
return (
<ZoomablePreviewSurface stageClassName="app-chat-prompt-card__preview-zoom-stage" contentClassName="app-chat-prompt-card__preview-zoom-content">
{frameNode}
</ZoomablePreviewSurface>
);
}
if (preview.type === 'resource' && preview.url) {
if (isAppRouteUrl(preview.url)) {
if (preview.type === 'resource' && normalizedPreviewUrl) {
const resourceKind =
isMarkdownResourceUrl(normalizedPreviewUrl || preview.url) || isMarkdownContentType(remoteContentType)
? 'markdown'
: classifyPreviewKind(normalizedPreviewUrl);
if (isAppRouteUrl(normalizedPreviewUrl)) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
URL은 resource preview로 . `/api/chat/resources/...html` .
URL은 resource preview로 . `/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html` .
</div>
);
}
@@ -542,33 +665,26 @@ function PromptPreviewSurface({
if (remoteContentType?.toLowerCase().includes('text/html') && isHtmlFallbackPreview(preview, remoteContent || '')) {
return (
<div className="app-chat-prompt-card__preview-placeholder">
fallback HTML이 . `type:"resource"` (`/api/chat/resources/...html`) .
fallback HTML이 . `type:"resource"` (`/api/chat/resources/...html`, `/api/resource-manager/preview/...html`, `resource/...html`) .
</div>
);
}
if (shouldRenderAsHtml && htmlDocument) {
return (
<iframe
title={preview.title?.trim() || 'prompt resource preview'}
srcDoc={htmlDocument}
className="app-chat-prompt-card__preview-frame"
sandbox="allow-same-origin"
/>
);
}
if (canRenderFramePreview(preview.url)) {
return (
<iframe
title={preview.title?.trim() || 'prompt resource preview'}
src={preview.url}
className="app-chat-prompt-card__preview-frame"
/>
);
}
return <div className="app-chat-prompt-card__preview-placeholder"> inline preview .</div>;
return (
<ChatPreviewBody
target={{
label: preview.title?.trim() || preview.alt?.trim() || 'prompt resource preview',
url: normalizedPreviewUrl,
kind: resourceKind,
}}
previewText={remoteContent || ''}
isPreviewLoading={isLoading}
previewError={loadError}
previewContentType={remoteContentType ?? undefined}
maxMarkdownBlocks={compact ? 5 : undefined}
renderHtmlAsFrame={htmlMode !== 'source'}
/>
);
}
return <div className="app-chat-prompt-card__preview-placeholder"> preview가 .</div>;
@@ -583,6 +699,7 @@ function PromptPreviewCard({
}) {
const preview = option.preview;
const { message } = App.useApp();
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview?.url);
if (!preview) {
return null;
@@ -614,7 +731,7 @@ function PromptPreviewCard({
aria-label={`${option.label} preview 열기`}
onClick={(event) => {
event.stopPropagation();
openChatExternalLink(preview.url ?? '', event);
openChatExternalLink(normalizedPreviewUrl || preview.url || '', event);
}}
/>
) : (
@@ -636,6 +753,15 @@ function PromptPreviewCard({
);
}
export function resolvePromptPreviewOption(
options: PromptOption[],
activePreviewOptionValue: string | null,
selectedValues: string[],
) {
const resolvedValue = resolvePromptPreviewOptionValue(options, activePreviewOptionValue, selectedValues);
return options.find((option) => option.value === resolvedValue) ?? null;
}
function buildInitialStepSelectionMap(target: PromptTarget, steps: PromptStep[]) {
return Object.fromEntries(
steps.map((step) => {
@@ -705,14 +831,18 @@ function buildPromptSelectionPayload(target: PromptTarget, stepSelections: Recor
}
const selectedValues = meaningfulSelections.flatMap((selection) => selection.selectedValues);
const aggregatedFreeText = meaningfulSelections
.map((selection) => selection.freeText.trim())
.filter(Boolean)
.join('\n\n');
return {
selectedValues,
freeText: '',
freeText: aggregatedFreeText,
stepSelections: meaningfulSelections,
summaryText: buildPromptDraftSummaryText(target, {
selectedValues,
freeText: '',
freeText: aggregatedFreeText,
stepSelections: meaningfulSelections,
}),
} satisfies PromptDraftSelection;
@@ -723,12 +853,14 @@ export function ChatPromptCard({
onSubmit,
readOnly = false,
onSelectionChange,
onSubmitted,
submittedSelection,
}: {
target: PromptTarget;
onSubmit: (payload: { text: string; mode: 'queue' | 'direct' }) => Promise<boolean>;
readOnly?: boolean;
onSelectionChange?: (selection: PromptDraftSelection | null) => void;
onSubmitted?: (selection: PromptDraftSelection) => void;
submittedSelection?: PromptDraftSelection | null;
}) {
const steps = useMemo(() => normalizePromptSteps(target), [target]);
@@ -740,7 +872,9 @@ export function ChatPromptCard({
const [submittedSummary, setSubmittedSummary] = useState('');
const [submittedFreeText, setSubmittedFreeText] = useState('');
const [expandedOptionValue, setExpandedOptionValue] = useState<string | null>(null);
const [expandedHtmlMode, setExpandedHtmlMode] = useState<'preview' | 'source'>('preview');
const [isCollapsed, setIsCollapsed] = useState(false);
const [activePreviewOptionValue, setActivePreviewOptionValue] = useState<string | null>(null);
const resolvedSelectedValues = target.selectedValues ?? [];
const resolvedSelectionSummary = buildSelectionText(target, resolvedSelectedValues);
const isResolved = resolvedSelectedValues.length > 0;
@@ -762,8 +896,17 @@ export function ChatPromptCard({
const expandedOption = steps
.flatMap((step) => step.options)
.find((option) => option.value === expandedOptionValue) ?? null;
useEffect(() => {
setExpandedHtmlMode('preview');
}, [expandedOptionValue]);
const activeStep = steps[Math.min(activeStepIndex, Math.max(steps.length - 1, 0))] ?? steps[0];
const activeSelection = activeStep ? stepSelections[activeStep.key] : undefined;
const previewableOptions = useMemo(() => getPreviewablePromptOptions(activeStep), [activeStep]);
const activePreviewOption = useMemo(
() => resolvePromptPreviewOption(previewableOptions, activePreviewOptionValue, activeSelection?.selectedValues ?? []),
[activePreviewOptionValue, activeSelection?.selectedValues, previewableOptions],
);
const selectionSummary = activeStep && activeSelection ? buildStepSelectionText(activeStep, activeSelection.selectedValues) : '';
const displayedSelectionSummary = displayedSubmittedSummary || (hasStepper ? buildPromptDraftSummaryText(target, buildPromptSelectionPayload(target, stepSelections) ?? {
selectedValues: [],
@@ -794,6 +937,21 @@ export function ChatPromptCard({
setActiveStepIndex(resolveCurrentStepIndex(steps, initialStepSelections, target.currentStepKey));
}, [initialStepSelections, steps, target.currentStepKey]);
useEffect(() => {
if (previewableOptions.length === 0) {
setActivePreviewOptionValue(null);
return;
}
setActivePreviewOptionValue((current) => {
if (current && previewableOptions.some((option) => option.value === current)) {
return current;
}
return null;
});
}, [previewableOptions]);
useEffect(() => {
if (isLocked) {
emitSelectionChange({});
@@ -841,7 +999,6 @@ export function ChatPromptCard({
return;
}
const trimmedFreeText = submittedSelection?.freeText ?? '';
const payload = buildPromptSelectionPayload(target, stepSelections);
if (!payload) {
@@ -850,7 +1007,7 @@ export function ChatPromptCard({
setIsSubmitting(true);
const isSent = await onSubmit({
text: buildPromptResponseText(target, payload, trimmedFreeText),
text: buildPromptResponseText(target, payload),
mode: activeStep.mode === 'direct' ? 'direct' : target.mode === 'direct' ? 'direct' : 'queue',
});
setIsSubmitting(false);
@@ -860,7 +1017,8 @@ export function ChatPromptCard({
}
setSubmittedSummary(payload.summaryText || buildPromptDraftSummaryText(target, payload));
setSubmittedFreeText(trimmedFreeText);
setSubmittedFreeText(payload.freeText);
onSubmitted?.(payload);
};
return (
@@ -970,6 +1128,26 @@ export function ChatPromptCard({
{hasStepper && activeStep.description ? (
<Paragraph className="app-chat-prompt-card__description">{activeStep.description}</Paragraph>
) : null}
{previewableOptions.length > 0 ? (
<div className="app-chat-prompt-card__preview-tabs-shell">
<Tabs
size="small"
className="app-chat-prompt-card__preview-tabs"
activeKey={activePreviewOption?.value}
onChange={(nextValue) => setActivePreviewOptionValue(nextValue)}
items={previewableOptions.map((option) => ({
key: option.value,
label: option.label,
}))}
/>
{activePreviewOption?.preview ? (
<PromptPreviewCard
option={activePreviewOption}
onOpenPreview={(nextOption) => setExpandedOptionValue(nextOption.value)}
/>
) : null}
</div>
) : null}
<div className="app-chat-prompt-card__options" role={activeStep.multiple ? 'group' : 'radiogroup'} aria-label={activeStep.title}>
{activeStep.options.map((option) => {
const isSelected = activeSelection?.selectedValues.includes(option.value) ?? false;
@@ -1041,12 +1219,7 @@ export function ChatPromptCard({
{option.description ? (
<span className="app-chat-prompt-card__option-description">{option.description}</span>
) : null}
{option.preview ? (
<PromptPreviewCard
option={option}
onOpenPreview={(nextOption) => setExpandedOptionValue(nextOption.value)}
/>
) : null}
{option.preview ? <span className="app-chat-prompt-card__option-preview-hint"> </span> : null}
</div>
);
})}
@@ -1143,18 +1316,36 @@ export function ChatPromptCard({
</div>
) : null}
</section>
<Modal
<FullscreenPreviewModal
open={Boolean(expandedOption?.preview)}
onCancel={() => setExpandedOptionValue(null)}
footer={null}
width="100vw"
onClose={() => setExpandedOptionValue(null)}
title={expandedOption?.preview?.title?.trim() || expandedOption?.label || 'preview'}
actions={
canShowHtmlPreviewActions(expandedOption?.preview) ? (
<>
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="HTML 실행 미리보기"
icon={<EyeOutlined />}
onClick={() => setExpandedHtmlMode('preview')}
/>
<Button
type="text"
className="fullscreen-preview-modal__icon-button"
aria-label="HTML 코드 보기"
icon={<CodeOutlined />}
onClick={() => setExpandedHtmlMode('source')}
/>
</>
) : null
}
className="app-chat-prompt-card__preview-modal"
title={null}
>
<div className="app-chat-prompt-card__preview-modal-surface">
{expandedOption?.preview ? <PromptPreviewSurface preview={expandedOption.preview} /> : null}
{expandedOption?.preview ? <PromptPreviewSurface preview={expandedOption.preview} htmlMode={expandedHtmlMode} /> : null}
</div>
</Modal>
</FullscreenPreviewModal>
</>
);
}

6
src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx Executable file → Normal file
View File

@@ -280,6 +280,12 @@ export function ChatRuntimeDashboard({
});
});
useEffect(() => {
return () => {
messageApi.destroy();
};
}, [messageApi]);
const loadLogDetail = async (requestId: string) => {
setIsLogLoading(true);
setLogLoadError('');

0
src/app/main/mainChatPanel/ErrorLogViewer.tsx Executable file → Normal file
View File

View File

@@ -1,8 +1,55 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeChatResourceUrl = normalizeChatResourceUrl;
exports.isMarkdownResourceUrl = isMarkdownResourceUrl;
exports.isMarkdownContentType = isMarkdownContentType;
var CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
var CHAT_PUBLIC_RESOURCE_MARKER = '/.codex_chat/';
var CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
var CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
var RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/';
var RESOURCE_MANAGER_ROOT_MARKER = 'resource/';
function normalizeResourceManagerPathSegment(segment) {
var normalized = String(segment !== null && segment !== void 0 ? segment : '').trim();
if (!normalized) {
return '';
}
try {
return encodeURIComponent(decodeURIComponent(normalized));
}
catch (_a) {
return encodeURIComponent(normalized);
}
}
function buildResourceManagerPreviewPath(value) {
var normalized = String(value !== null && value !== void 0 ? value : '').trim().replace(/\\/g, '/');
var matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i);
var resourcePath = String((matchedResourcePath === null || matchedResourcePath === void 0 ? void 0 : matchedResourcePath[1]) !== null && (matchedResourcePath === null || matchedResourcePath === void 0 ? void 0 : matchedResourcePath[1]) !== void 0 ? (matchedResourcePath === null || matchedResourcePath === void 0 ? void 0 : matchedResourcePath[1]) : '').trim().replace(/^\/+/, '');
if (!resourcePath) {
return '';
}
var relativePath = resourcePath.slice(RESOURCE_MANAGER_ROOT_MARKER.length).replace(/^\/+/, '');
if (!relativePath) {
return '';
}
var encodedPath = relativePath
.split('/')
.filter(Boolean)
.map(function (segment) { return normalizeResourceManagerPathSegment(segment); })
.join('/');
return encodedPath ? "".concat(RESOURCE_MANAGER_PREVIEW_MARKER).concat(encodedPath) : '';
}
function resolveLegacyExternalImageUrl(value) {
var normalized = String(value !== null && value !== void 0 ? value : '').trim();
var match = normalized.match(/^\/api\/resource-manager\/preview\/(\d+)\/([^/?#]+\.(?:png|jpe?g|gif|webp|svg|bmp|ico))(?:[?#].*)?$/i);
if (!match) {
return '';
}
var directory = match[1], fileName = match[2];
if (!/^\d+_image\d+[_\d-]*\.(?:png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(fileName)) {
return '';
}
return "https://tong.visitkorea.or.kr/cms/resource/".concat(directory, "/").concat(fileName);
}
function extractEmbeddedResourcePath(value) {
var normalized = String(value !== null && value !== void 0 ? value : '').trim();
if (!normalized) {
@@ -10,23 +57,64 @@ function extractEmbeddedResourcePath(value) {
}
var apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
if (apiMarkerIndex >= 0) {
return normalized.slice(apiMarkerIndex);
var apiPath = normalized.slice(apiMarkerIndex);
var dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
return dotCodexIndex >= 0
? "".concat(CHAT_API_RESOURCE_MARKER).concat(apiPath.slice(dotCodexIndex + 1))
: apiPath;
}
var publicMarkerIndex = normalized.lastIndexOf(CHAT_PUBLIC_RESOURCE_MARKER);
if (publicMarkerIndex >= 0) {
return normalized.slice(publicMarkerIndex);
var publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
if (publicDotCodexIndex >= 0) {
return "".concat(CHAT_API_RESOURCE_MARKER).concat(normalized.slice(publicDotCodexIndex + 8));
}
var dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
if (dotCodexIndex >= 0) {
return "".concat(CHAT_API_RESOURCE_MARKER).concat(normalized.slice(dotCodexIndex + 1));
}
if (normalized === 'resource' || normalized === '/resource' || normalized.startsWith('resource/') || normalized.includes('/resource/')) {
var resourceManagerPreviewPath = buildResourceManagerPreviewPath(normalized);
if (resourceManagerPreviewPath) {
return resourceManagerPreviewPath;
}
}
return normalized;
}
function normalizeChatResourceUrl(value) {
var normalized = extractEmbeddedResourcePath(value);
var normalized = extractKnownPreviewPath(value);
var legacyExternalImageUrl = resolveLegacyExternalImageUrl(normalized);
if (legacyExternalImageUrl) {
return legacyExternalImageUrl;
}
if (typeof window === 'undefined') {
return normalized;
}
try {
return new URL(normalized, window.location.href).toString();
var resolvedUrl = new URL(normalized, window.location.href);
return resolvedUrl.toString();
}
catch (_a) {
return normalized;
}
}
function isMarkdownResourceUrl(value) {
var normalized = String(value !== null && value !== void 0 ? value : '').trim();
if (!normalized) {
return false;
}
try {
var resolvedUrl = new URL(normalized, 'http://localhost');
return /\.(md|markdown)$/i.test(resolvedUrl.pathname);
}
catch (_a) {
return /\.(md|markdown)(?:$|[?#])/i.test(normalized);
}
}
function isMarkdownContentType(contentType) {
if (!contentType) {
return false;
}
var normalized = contentType.toLowerCase();
return (normalized.includes('text/markdown') ||
normalized.includes('text/x-markdown') ||
normalized.includes('application/markdown'));
}

View File

@@ -1,6 +1,65 @@
import { getRegisteredAccessToken } from '../tokenAccess';
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/';
const RESOURCE_MANAGER_ROOT_MARKER = 'resource/';
function normalizeResourceManagerPathSegment(segment: string) {
const normalized = String(segment ?? '').trim();
if (!normalized) {
return '';
}
try {
return encodeURIComponent(decodeURIComponent(normalized));
} catch {
return encodeURIComponent(normalized);
}
}
function buildResourceManagerPreviewPath(value: string) {
const normalized = String(value ?? '').trim().replace(/\\/g, '/');
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const resourcePath = String(matchedResourcePath ?? '').trim().replace(/^\/+/, '');
if (!resourcePath) {
return '';
}
const relativePath = resourcePath.slice(RESOURCE_MANAGER_ROOT_MARKER.length).replace(/^\/+/, '');
if (!relativePath) {
return '';
}
const encodedPath = relativePath
.split('/')
.filter(Boolean)
.map((segment) => normalizeResourceManagerPathSegment(segment))
.join('/');
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
}
function resolveLegacyExternalImageUrl(value: string) {
const normalized = String(value ?? '').trim();
const match = normalized.match(/^\/api\/resource-manager\/preview\/(\d+)\/([^/?#]+\.(?:png|jpe?g|gif|webp|svg|bmp|ico))(?:[?#].*)?$/i);
if (!match) {
return '';
}
const [, directory, fileName] = match;
if (!/^\d+_image\d+[_\d-]*\.(?:png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(fileName)) {
return '';
}
return `https://tong.visitkorea.or.kr/cms/resource/${directory}/${fileName}`;
}
function extractEmbeddedResourcePath(value: string) {
const normalized = String(value ?? '').trim();
@@ -31,19 +90,92 @@ function extractEmbeddedResourcePath(value: string) {
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
}
if (normalized === 'resource' || normalized === '/resource' || normalized.startsWith('resource/') || normalized.includes('/resource/')) {
const resourceManagerPreviewPath = buildResourceManagerPreviewPath(normalized);
if (resourceManagerPreviewPath) {
return resourceManagerPreviewPath;
}
}
return normalized;
}
function extractKnownPreviewPath(value: string) {
const normalized = String(value ?? '').trim();
if (!normalized) {
return '';
}
try {
const parsed = new URL(normalized);
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
return pathname;
}
return normalized;
} catch {
return extractEmbeddedResourcePath(normalized);
}
}
export function normalizeChatResourceUrl(value: string) {
const normalized = extractEmbeddedResourcePath(value);
const normalized = extractKnownPreviewPath(value);
const legacyExternalImageUrl = resolveLegacyExternalImageUrl(normalized);
if (legacyExternalImageUrl) {
return legacyExternalImageUrl;
}
if (typeof window === 'undefined') {
return normalized;
}
try {
return new URL(normalized, window.location.href).toString();
const resolvedUrl = new URL(normalized, window.location.href);
if (resolvedUrl.pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER) && !resolvedUrl.searchParams.has('token')) {
const token = getRegisteredAccessToken();
if (token) {
resolvedUrl.searchParams.set('token', token);
}
}
return resolvedUrl.toString();
} catch {
return normalized;
}
}
export function isMarkdownResourceUrl(value?: string | null) {
const normalized = String(value ?? '').trim();
if (!normalized) {
return false;
}
try {
const resolvedUrl = new URL(normalized, 'http://localhost');
return /\.(md|markdown)$/i.test(resolvedUrl.pathname);
} catch {
return /\.(md|markdown)(?:$|[?#])/i.test(normalized);
}
}
export function isMarkdownContentType(contentType?: string | null) {
if (!contentType) {
return false;
}
const normalized = contentType.toLowerCase();
return (
normalized.includes('text/markdown') ||
normalized.includes('text/x-markdown') ||
normalized.includes('application/markdown')
);
}

View File

@@ -1372,8 +1372,9 @@ function upsertChatMessage(previous, incoming) {
return message.id === incoming.id ||
Boolean(incoming.clientRequestId &&
message.clientRequestId &&
incoming.author === 'user' &&
message.author === 'user' &&
(incoming.author === 'user' || incoming.author === 'codex') &&
(message.author === 'user' || message.author === 'codex') &&
incoming.author === message.author &&
incoming.clientRequestId === message.clientRequestId);
});
if (existingIndex < 0) {
@@ -1617,7 +1618,22 @@ function mergeRecoveredChatMessages(previous, incoming) {
}
return __assign(__assign(__assign({}, existingMessage), serverMessage), { deliveryStatus: null, retryCount: 0 });
});
var unmatchedLocalMessages = Array.from(previousBuckets.values()).flat();
var incomingUserRequestIds = new Set(incoming
.filter(function (message) { return message.author === 'user'; })
.map(function (message) { return getChatMessageRequestId(message); })
.filter(Boolean));
var unmatchedLocalMessages = Array.from(previousBuckets.values())
.flat()
.filter(function (message) {
if (!isMissingRequestMessage(message)) {
return true;
}
var requestId = getChatMessageRequestId(message);
if (!requestId) {
return true;
}
return !incomingUserRequestIds.has(requestId);
});
var nextMessages = sortConversationMessages(__spreadArray(__spreadArray([], mergedServerMessages, true), unmatchedLocalMessages, true));
return areChatMessagesEquivalent(previous, nextMessages) ? previous : nextMessages;
}

View File

@@ -2,7 +2,9 @@ import type { Dispatch, SetStateAction } from 'react';
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
import { reportClientError } from '../errorLogApi';
import { notifyNotificationMessagesUpdated } from '../notificationApi';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { resolveConversationUnreadMergeState } from './conversationUnread';
import type {
ChatActivityEvent,
ChatConversationActivityLog,
@@ -10,6 +12,8 @@ import type {
ChatComposerAttachment,
ChatConversationRequest,
ChatConversationSummary,
ChatSourceChangeSnapshot,
ChatSourceChangeSnapshotListResponse,
ChatJobEvent,
ChatMessage,
ChatRuntimeJobDetail,
@@ -62,8 +66,87 @@ function getConversationLastMessageSortTime(item: ChatConversationSummary) {
);
}
function pickPreferredConversationSummary(
left: ChatConversationSummary,
right: ChatConversationSummary,
) {
const leftTime = getConversationLastMessageSortTime(left);
const rightTime = getConversationLastMessageSortTime(right);
if (rightTime !== leftTime) {
return rightTime > leftTime ? right : left;
}
const leftUpdatedAt = toConversationSortTime(left.updatedAt);
const rightUpdatedAt = toConversationSortTime(right.updatedAt);
if (rightUpdatedAt !== leftUpdatedAt) {
return rightUpdatedAt > leftUpdatedAt ? right : left;
}
return right;
}
function mergeConversationSummaries(
existing: ChatConversationSummary,
incoming: ChatConversationSummary,
) {
const preferred = pickPreferredConversationSummary(existing, incoming);
const fallback = preferred === existing ? incoming : existing;
return {
...fallback,
...preferred,
clientId: preferred.clientId ?? fallback.clientId,
isDraftOnly: preferred.isDraftOnly ?? fallback.isDraftOnly,
title: preferred.title.trim() || fallback.title.trim(),
requestBadgeLabel: preferred.requestBadgeLabel?.trim() || fallback.requestBadgeLabel?.trim() || null,
chatTypeId: preferred.chatTypeId?.trim() || fallback.chatTypeId?.trim() || null,
lastChatTypeId: preferred.lastChatTypeId?.trim() || fallback.lastChatTypeId?.trim() || null,
generalSectionName: preferred.generalSectionName?.trim() || fallback.generalSectionName?.trim() || null,
contextLabel: preferred.contextLabel?.trim() || fallback.contextLabel?.trim() || null,
contextDescription: preferred.contextDescription?.trim() || fallback.contextDescription?.trim() || null,
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus,
currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null,
currentQueueSize: Math.max(preferred.currentQueueSize ?? 0, fallback.currentQueueSize ?? 0),
currentStatusUpdatedAt:
preferred.currentStatusUpdatedAt?.trim() || fallback.currentStatusUpdatedAt?.trim() || null,
isPendingWork: preferred.isPendingWork ?? fallback.isPendingWork,
pendingWorkReason: preferred.pendingWorkReason ?? fallback.pendingWorkReason,
lastRequestPreview: preferred.lastRequestPreview.trim() || fallback.lastRequestPreview.trim(),
lastMessagePreview: preferred.lastMessagePreview.trim() || fallback.lastMessagePreview.trim(),
lastResponsePreview: preferred.lastResponsePreview.trim() || fallback.lastResponsePreview.trim(),
createdAt: preferred.createdAt.trim() || fallback.createdAt.trim(),
updatedAt: preferred.updatedAt.trim() || fallback.updatedAt.trim(),
lastMessageAt: preferred.lastMessageAt?.trim() || fallback.lastMessageAt?.trim() || null,
};
}
export function sortChatConversationSummaries(items: ChatConversationSummary[]) {
return [...items].sort((left, right) => {
const dedupedItems = items.reduce<ChatConversationSummary[]>((result, item) => {
const sessionId = item.sessionId.trim();
if (!sessionId) {
result.push(item);
return result;
}
const existingIndex = result.findIndex((candidate) => candidate.sessionId.trim() === sessionId);
if (existingIndex < 0) {
result.push(item);
return result;
}
const nextItems = [...result];
nextItems[existingIndex] = mergeConversationSummaries(nextItems[existingIndex] as ChatConversationSummary, item);
return nextItems;
}, []);
return dedupedItems.sort((left, right) => {
const leftTime = getConversationLastMessageSortTime(left);
const rightTime = getConversationLastMessageSortTime(right);
@@ -75,6 +158,44 @@ export function sortChatConversationSummaries(items: ChatConversationSummary[])
});
}
export function getDefaultRequestStatusMessage(status: ChatConversationRequest['status']) {
switch (status) {
case 'accepted':
return '요청을 접수했습니다.';
case 'queued':
return '대기열 등록';
case 'started':
return '요청 처리 중';
case 'completed':
return '요청 처리 완료';
case 'failed':
return '요청 처리 실패';
case 'cancelled':
return '요청 실행 중단';
case 'removed':
return '요청 기록이 제거되었습니다.';
default:
return null;
}
}
export function mergeConversationRequestStatusMessage(
previousItem: Pick<ChatConversationRequest, 'status' | 'statusMessage'> | null | undefined,
nextItem: Pick<ChatConversationRequest, 'status' | 'statusMessage'>,
) {
const nextStatusMessage = nextItem.statusMessage?.trim() || '';
if (nextStatusMessage) {
return nextStatusMessage;
}
if (!previousItem || previousItem.status !== nextItem.status) {
return getDefaultRequestStatusMessage(nextItem.status);
}
return previousItem.statusMessage?.trim() || getDefaultRequestStatusMessage(nextItem.status);
}
export const CHAT_CONNECTION = {
reconnectDelayMs: 1500,
connectTimeoutMs: CONNECT_TIMEOUT_MS,
@@ -570,6 +691,22 @@ function mergeActivityLines(existingLines: string[], incomingLines: string[]) {
return merged;
}
function mergeActivityLineAtPosition(existingLines: string[], incomingLine: string, lineNo?: number) {
const normalizedLine = incomingLine.trim();
if (!normalizedLine) {
return existingLines;
}
if (!Number.isInteger(lineNo) || Number(lineNo) <= 0) {
return mergeActivityLines(existingLines, [normalizedLine]);
}
const nextLines = [...existingLines];
nextLines[Number(lineNo) - 1] = normalizedLine;
return nextLines.filter(Boolean);
}
function buildActivityMessageIndex(messages: ChatMessage[]) {
const indexByRequestId = new Map<string, number>();
@@ -675,7 +812,7 @@ export function appendActivityEventToMessages(previous: ChatMessage[], event: Ch
const activityMessageIndex = buildActivityMessageIndex(previous);
const existingIndex = activityMessageIndex.get(requestId);
const existingMessage = existingIndex == null ? undefined : previous[existingIndex];
const mergedLines = mergeActivityLines(getActivityLogLines(existingMessage), [event.line]);
const mergedLines = mergeActivityLineAtPosition(getActivityLogLines(existingMessage), event.line, event.lineNo);
const nextMessage = createActivityLogPlaceholder(requestId, mergedLines);
if (!nextMessage) {
@@ -1177,6 +1314,13 @@ export async function fetchChatRuntimeSnapshot() {
return response.item;
}
export async function fetchChatSourceChanges(limit = 300) {
const query = new URLSearchParams();
query.set('limit', String(Math.max(1, Math.min(500, Math.round(limit)))));
const response = await requestChatApi<ChatSourceChangeSnapshotListResponse>(`/source-changes?${query.toString()}`);
return response.items.map((item) => normalizeChatSourceChangeSnapshot(item));
}
export async function fetchChatRuntimeJobDetail(requestId: string) {
const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeJobDetail }>(
`/runtime/jobs/${encodeURIComponent(requestId)}`,
@@ -1217,6 +1361,32 @@ export async function rollbackChatRuntimeJob(requestId: string, sessionId?: stri
return response.rolledBack;
}
function normalizeChatSourceChangeSnapshot(item: ChatSourceChangeSnapshot): ChatSourceChangeSnapshot {
return {
...item,
clientId: item.clientId?.trim() || null,
conversationTitle: item.conversationTitle.trim() || '새 대화',
chatTypeId: item.chatTypeId?.trim() || null,
chatTypeLabel: item.chatTypeLabel.trim(),
requestId: item.requestId.trim(),
requestTitle: item.requestTitle.trim() || item.requestId.trim(),
questionText: item.questionText,
answerText: item.answerText,
status: item.status,
sourceChangedAt: item.sourceChangedAt,
updatedAt: item.updatedAt,
featureTags: item.featureTags.map((value) => value.trim()).filter(Boolean),
changedFiles: item.changedFiles.map((value) => value.trim()).filter(Boolean),
currentSourceFiles: item.currentSourceFiles.map((value) => value.trim()).filter(Boolean),
diffBlocks: item.diffBlocks.map((value) => value.trim()).filter(Boolean),
hasSourceChanges: item.hasSourceChanges === true,
reviewStatus: item.reviewStatus === 'reviewed' ? 'reviewed' : 'not-reviewed',
sourceChangeKind: item.sourceChangeKind === 'verification-group' ? 'verification-group' : 'request',
sourceEntryIds: (Array.isArray(item.sourceEntryIds) ? item.sourceEntryIds : []).map((value) => String(value).trim()).filter(Boolean),
conversationDeletedAt: item.conversationDeletedAt?.trim() || null,
};
}
export async function uploadChatComposerFile(sessionId: string, file: File) {
const normalizedSessionId = sessionId.trim();
const resolvedMimeType = resolveUploadMimeType(file);
@@ -1293,6 +1463,7 @@ export async function uploadChatComposerFile(sessionId: string, file: File) {
export async function createChatConversationRoom(args: {
sessionId: string;
title?: string;
requestBadgeLabel?: string | null;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
@@ -1307,6 +1478,7 @@ export async function createChatConversationRoom(args: {
body: JSON.stringify({
sessionId: args.sessionId,
title: args.title ?? '새 대화',
requestBadgeLabel: args.requestBadgeLabel ?? null,
chatTypeId: args.chatTypeId ?? null,
lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null,
generalSectionName: args.generalSectionName ?? null,
@@ -1345,6 +1517,7 @@ export async function updateChatConversationRoom(
sessionId: string,
payload: {
title?: string;
requestBadgeLabel?: string | null;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
@@ -1464,8 +1637,9 @@ export function upsertChatMessage(previous: ChatMessage[], incoming: ChatMessage
Boolean(
incoming.clientRequestId &&
message.clientRequestId &&
incoming.author === 'user' &&
message.author === 'user' &&
(incoming.author === 'user' || incoming.author === 'codex') &&
(message.author === 'user' || message.author === 'codex') &&
incoming.author === message.author &&
incoming.clientRequestId === message.clientRequestId,
),
);
@@ -1800,7 +1974,27 @@ export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: Ch
};
});
const unmatchedLocalMessages = Array.from(previousBuckets.values()).flat();
const incomingUserRequestIds = new Set(
incoming
.filter((message) => message.author === 'user')
.map((message) => getChatMessageRequestId(message))
.filter(Boolean),
);
const unmatchedLocalMessages = Array.from(previousBuckets.values())
.flat()
.filter((message) => {
if (!isMissingRequestMessage(message)) {
return true;
}
const requestId = getChatMessageRequestId(message);
if (!requestId) {
return true;
}
return !incomingUserRequestIds.has(requestId);
});
const nextMessages = sortConversationMessages([...mergedServerMessages, ...unmatchedLocalMessages]);
return areChatMessagesEquivalent(previous, nextMessages) ? previous : nextMessages;
@@ -1866,6 +2060,11 @@ export async function handleChatServerEvent({
return;
}
if (payload.type === 'notification:messages-updated') {
notifyNotificationMessagesUpdated();
return;
}
if (payload.type === 'chat:error') {
setMessages((previous) => [...previous, createLocalMessage(payload.payload.message)]);
}

View File

@@ -0,0 +1,34 @@
function isClipboardImageFile(file: File) {
const normalizedType = String(file.type ?? '').trim().toLowerCase();
if (normalizedType.startsWith('image/')) {
return true;
}
const normalizedName = String(file.name ?? '').trim().toLowerCase();
return /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(normalizedName);
}
function isGeneratedClipboardImageName(file: File) {
const normalizedName = String(file.name ?? '').trim().toLowerCase();
if (!normalizedName) {
return true;
}
return /^(image|clipboard|pasted image)(?:[-\s]?\d+)?(?: \(\d+\))?\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif)$/i.test(
normalizedName,
);
}
export function buildComposerFilePickKey(file: File) {
const normalizedType = String(file.type ?? '').trim().toLowerCase();
const normalizedName = String(file.name ?? '').trim().toLowerCase();
const shouldIgnoreName = isClipboardImageFile(file) && isGeneratedClipboardImageName(file);
if (shouldIgnoreName) {
return `clipboard-image:${file.size}:${normalizedType}`;
}
return `${normalizedName}:${file.size}:${normalizedType}:${file.lastModified}`;
}

View File

@@ -0,0 +1,33 @@
import type { ChatConversationSummary } from './types';
function toConversationActivityTime(value: string | null | undefined) {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
export function resolveConversationUnreadMergeState(
previousItem: Pick<ChatConversationSummary, 'hasUnreadResponse' | 'lastMessageAt' | 'updatedAt'>,
nextItem: Pick<ChatConversationSummary, 'hasUnreadResponse' | 'lastMessageAt' | 'updatedAt'>,
) {
if (!previousItem.hasUnreadResponse && nextItem.hasUnreadResponse) {
const previousActivityTime = Math.max(
toConversationActivityTime(previousItem.lastMessageAt),
toConversationActivityTime(previousItem.updatedAt),
);
const nextActivityTime = Math.max(
toConversationActivityTime(nextItem.lastMessageAt),
toConversationActivityTime(nextItem.updatedAt),
);
// Keep a locally-cleared unread state until a newer response actually arrives.
if (nextActivityTime <= previousActivityTime) {
return false;
}
}
return nextItem.hasUnreadResponse;
}

0
src/app/main/mainChatPanel/errorLogUtils.tsx Executable file → Normal file
View File

0
src/app/main/mainChatPanel/errorLogUtils.types.ts Executable file → Normal file
View File

View File

@@ -18,9 +18,11 @@ export {
deleteChatConversationRoom,
fetchChatConversationDetail,
fetchChatConversations,
fetchChatSourceChanges,
fetchChatRuntimeJobDetail,
fetchChatRuntimeSnapshot,
getStoredChatSessionLastTypeId,
mergeConversationRequestStatusMessage,
isMissingRequestMessage,
isPreparingChatReplyText,
getChatClientSessionId,

View File

@@ -1,51 +1,4 @@
const AUTO_DETECTED_PREVIEW_URL_PATTERN =
/(https?:\/\/[^\s<>)\]]+|\/(?:[A-Za-z0-9._~%-][A-Za-z0-9._~:/?#[\]@!$&'*+,;=%-]*))/g;
const LOCAL_RESOURCE_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
const PREVIEWABLE_FILE_EXTENSION_PATTERN =
/\.(png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|md|markdown|diff|patch|ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml|txt|log|csv|pdf)$/i;
function stripCodeFenceBlocks(text: string) {
return String(text ?? '').replace(/```[\s\S]*?```/g, '');
}
function trimAutoDetectedUrl(value: string) {
return String(value ?? '').trim().replace(/[`\])}>.,;!?]+$/g, '');
}
function isLikelyLocalPreviewUrl(value: string) {
if (LOCAL_RESOURCE_PREFIXES.some((prefix) => value.startsWith(prefix))) {
return true;
}
const pathname = value.split(/[?#]/, 1)[0] ?? '';
return PREVIEWABLE_FILE_EXTENSION_PATTERN.test(pathname);
}
export function extractAutoDetectedPreviewUrls(text: string) {
const normalized = stripCodeFenceBlocks(text);
const urls: string[] = [];
for (const match of normalized.matchAll(AUTO_DETECTED_PREVIEW_URL_PATTERN)) {
const value = trimAutoDetectedUrl(match[0] ?? '');
if (!value) {
continue;
}
const startIndex = match.index ?? -1;
const previousChar = startIndex > 0 ? normalized[startIndex - 1] : '';
// Ignore HTML closing tags like </div> that were being misread as same-origin routes.
if (previousChar === '<') {
continue;
}
if (value.startsWith('/') && !isLikelyLocalPreviewUrl(value)) {
continue;
}
urls.push(value);
}
return urls;
void text;
return [];
}

View File

@@ -2,12 +2,16 @@ import type { ChatMessagePart } from './types';
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
const PROMPT_BLOCK_START_PATTERN = /^\s*\[\[prompt:\s*$/i;
const PROMPT_BLOCK_END_PATTERN = /^\s*\]\]\s*$/;
const PROMPT_CODE_BLOCK_START_PATTERN = /^\s*```(?:json|prompt)(?:\s+prompt)?\s*$/i;
const CODE_BLOCK_END_PATTERN = /^\s*```\s*$/;
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/';
const RESOURCE_MANAGER_ROOT_MARKER = 'resource/';
type PromptPart = Extract<ChatMessagePart, { type: 'prompt' }>;
type PromptOption = PromptPart['options'][number];
type PromptPreview = NonNullable<PromptOption['preview']>;
@@ -17,6 +21,65 @@ function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function normalizeResourceManagerPathSegment(segment: string) {
const normalized = normalizeText(segment);
if (!normalized) {
return '';
}
try {
return encodeURIComponent(decodeURIComponent(normalized));
} catch {
return encodeURIComponent(normalized);
}
}
function buildResourceManagerPreviewUrl(value: string) {
const normalized = normalizeText(value).replace(/\\/g, '/');
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
if (!resourcePath) {
return '';
}
const relativePath = resourcePath.slice(RESOURCE_MANAGER_ROOT_MARKER.length).replace(/^\/+/, '');
if (!relativePath) {
return '';
}
const encodedPath = relativePath
.split('/')
.filter(Boolean)
.map((segment) => normalizeResourceManagerPathSegment(segment))
.join('/');
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
}
function extractKnownPreviewPath(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
try {
const parsed = new URL(normalized);
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
return pathname;
}
return normalized;
} catch {
return '';
}
}
function normalizeUrl(value: string) {
const normalized = normalizeText(value);
@@ -24,6 +87,12 @@ function normalizeUrl(value: string) {
return '';
}
const knownPreviewPath = extractKnownPreviewPath(normalized);
if (knownPreviewPath) {
return knownPreviewPath;
}
const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i);
if (malformedResourceMatch?.[1]) {
return `/${malformedResourceMatch[1]}`;
@@ -48,6 +117,14 @@ function normalizeUrl(value: string) {
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
}
if (normalized === 'resource' || normalized === '/resource' || normalized.startsWith('resource/') || normalized.includes('/resource/')) {
const resourceManagerPreviewUrl = buildResourceManagerPreviewUrl(normalized);
if (resourceManagerPreviewUrl) {
return resourceManagerPreviewUrl;
}
}
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
return normalized;
}
@@ -193,59 +270,10 @@ function resolveLinkCardUrlAndActionLabel(rawUrl: string, rawActionLabel?: strin
};
}
function hasKnownFileExtension(url: string) {
const pathname = url.split('?')[0] ?? '';
return /\.[a-z0-9]{1,8}$/i.test(pathname);
}
function isInternalResourceUrl(url: string) {
return RESOURCE_PATH_PREFIXES.some((prefix) => url.startsWith(prefix));
}
function isStructuredLinkCardCandidate(url: string) {
const normalized = normalizeUrl(url);
if (!normalized) {
return false;
}
if (isInternalResourceUrl(normalized)) {
return false;
}
return /^https?:\/\//i.test(normalized) && !hasKnownFileExtension(normalized);
}
function buildFallbackLinkTitle(url: string) {
try {
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
return lastSegment || parsed.hostname || normalizeText(url);
} catch {
return normalizeText(url);
}
}
function normalizeStandaloneTitle(value: string) {
return value
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+)?/, '')
.replace(/[`'"]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function resolveStandaloneLinkTitle(keptLines: string[], url: string) {
for (let index = keptLines.length - 1; index >= 0; index -= 1) {
const candidate = normalizeStandaloneTitle(keptLines[index] ?? '');
if (candidate) {
return candidate;
}
}
return buildFallbackLinkTitle(url);
}
function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
const segments = rawBody
.split('|')
@@ -330,6 +358,17 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
};
}
function buildPromptPartFromBlock(rawBody: string) {
const trimmed = rawBody.trim();
if (!trimmed) {
return null;
}
const promptWrapperMatched = trimmed.match(/^\[\[prompt:\s*([\s\S]*?)\s*\]\]$/i);
return buildPromptPart(promptWrapperMatched?.[1] ?? trimmed);
}
export function extractChatMessageParts(text: string) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
@@ -382,7 +421,8 @@ export function extractChatMessageParts(text: string) {
return true;
};
for (const line of lines) {
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex] ?? '';
const promptMatched = line.match(PROMPT_LINE_PATTERN);
if (promptMatched) {
@@ -392,29 +432,65 @@ export function extractChatMessageParts(text: string) {
continue;
}
if (PROMPT_BLOCK_START_PATTERN.test(line)) {
const wrappedLines = [line];
const promptBodyLines: string[] = [];
let cursor = lineIndex + 1;
let foundBlockEnd = false;
for (; cursor < lines.length; cursor += 1) {
const nextLine = lines[cursor] ?? '';
wrappedLines.push(nextLine);
if (PROMPT_BLOCK_END_PATTERN.test(nextLine)) {
foundBlockEnd = true;
break;
}
promptBodyLines.push(nextLine);
}
if (foundBlockEnd && pushPart(buildPromptPartFromBlock(promptBodyLines.join('\n')))) {
lineIndex = cursor;
continue;
}
keptLines.push(...wrappedLines);
lineIndex = foundBlockEnd ? cursor : lines.length;
continue;
}
if (PROMPT_CODE_BLOCK_START_PATTERN.test(line)) {
const fencedLines = [line];
const jsonBodyLines: string[] = [];
let cursor = lineIndex + 1;
let foundFenceEnd = false;
for (; cursor < lines.length; cursor += 1) {
const nextLine = lines[cursor] ?? '';
fencedLines.push(nextLine);
if (CODE_BLOCK_END_PATTERN.test(nextLine)) {
foundFenceEnd = true;
break;
}
jsonBodyLines.push(nextLine);
}
if (foundFenceEnd && pushPart(buildPromptPartFromBlock(jsonBodyLines.join('\n')))) {
lineIndex = cursor;
continue;
}
keptLines.push(...fencedLines);
lineIndex = foundFenceEnd ? cursor : lines.length;
continue;
}
const matched = line.match(LINK_CARD_LINE_PATTERN);
if (!matched) {
const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN);
if (markdownLinkMatch) {
const [, rawTitle, rawUrl] = markdownLinkMatch;
if (isStructuredLinkCardCandidate(rawUrl ?? '')) {
if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) {
continue;
}
}
}
const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN);
if (standaloneUrlMatch) {
const rawUrl = standaloneUrlMatch[1] ?? '';
if (isStructuredLinkCardCandidate(rawUrl)) {
if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) {
continue;
}
}
}
keptLines.push(line);
continue;
}

View File

@@ -1,11 +1,9 @@
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractChatMessageParts } from './messageParts';
import { extractHiddenPreviewUrls } from './previewMarkers';
import { classifyPreviewKind, type PreviewKind } from './previewKind';
import type { ChatMessage } from './types';
export type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
export type PreviewItem = {
id: string;
label: string;
@@ -125,59 +123,6 @@ function shouldHideInternalChatResource(url: string) {
return false;
}
function isPreviewRouteUrl(url: string) {
if (typeof window === 'undefined') {
return false;
}
try {
const parsed = new URL(url, window.location.origin);
const pathname = parsed.pathname.toLowerCase();
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
} catch {
return false;
}
}
export function classifyPreviewKind(url: string): PreviewKind {
const pathname = url.toLowerCase().split('?')[0] ?? '';
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
return 'image';
}
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
return 'video';
}
if (/\.(md|markdown)$/i.test(pathname)) {
return 'markdown';
}
if (/\.(diff|patch)$/i.test(pathname)) {
return 'diff';
}
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
return 'code';
}
if (/\.(txt|log|csv)$/i.test(pathname)) {
return 'document';
}
if (/\.pdf$/i.test(pathname)) {
return 'pdf';
}
if (isPreviewRouteUrl(url)) {
return 'document';
}
return 'file';
}
export function buildPreviewLabel(url: string, source: PreviewItem['source']) {
try {
const parsed = new URL(url);
@@ -227,7 +172,6 @@ export function extractPreviewItems(messages: ChatMessage[]) {
const matches = [
...extractHiddenPreviewUrls(message.text),
...structuredLinkUrls,
...extractAutoDetectedPreviewUrls(message.text),
];
matches.forEach((matchedUrl) => {

View File

@@ -0,0 +1,111 @@
export type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
function isPreviewRouteUrl(url: string) {
if (typeof window === 'undefined') {
return false;
}
try {
const parsed = new URL(url, window.location.origin);
const pathname = parsed.pathname.toLowerCase();
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
} catch {
return false;
}
}
function hasImageFormatSearchParam(url: URL) {
const imageFormatPattern = /^(?:png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i;
const candidateKeys = ['fm', 'format', 'ext', 'output-format'];
return candidateKeys.some((key) => {
const value = url.searchParams.get(key)?.trim() ?? '';
return imageFormatPattern.test(value);
});
}
function isLikelyRemoteImageUrl(url: string) {
try {
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
if (!/^https?:$/i.test(parsed.protocol)) {
return false;
}
const pathname = parsed.pathname.toLowerCase();
if (/\.(html?|php|aspx?)$/i.test(pathname)) {
return false;
}
if (hasImageFormatSearchParam(parsed)) {
return true;
}
const hostname = parsed.hostname.toLowerCase();
const isImageCdnHost =
hostname.startsWith('images.') ||
hostname.startsWith('img.') ||
hostname.includes('unsplash.com') ||
hostname.includes('pexels.com') ||
hostname.includes('pixabay.com') ||
hostname.includes('googleusercontent.com') ||
hostname.includes('imgur.com');
if (isImageCdnHost) {
return true;
}
const hasImageSizingParams = ['w', 'width', 'h', 'height', 'fit', 'crop', 'q', 'auto', 'dpr'].some((key) =>
parsed.searchParams.has(key),
);
const hasImageLikePath = /(image|images|photo|photos|img|media)/i.test(`${hostname}${pathname}`);
return hasImageSizingParams && hasImageLikePath;
} catch {
return false;
}
}
export function classifyPreviewKind(url: string): PreviewKind {
const pathname = url.toLowerCase().split('?')[0] ?? '';
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
return 'image';
}
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
return 'video';
}
if (/\.(md|markdown)$/i.test(pathname)) {
return 'markdown';
}
if (/\.(diff|patch)$/i.test(pathname)) {
return 'diff';
}
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
return 'code';
}
if (/\.(txt|log|csv)$/i.test(pathname)) {
return 'document';
}
if (/\.pdf$/i.test(pathname)) {
return 'pdf';
}
if (isLikelyRemoteImageUrl(url)) {
return 'image';
}
if (isPreviewRouteUrl(url)) {
return 'document';
}
return 'file';
}

View File

@@ -0,0 +1,15 @@
export function resolvePromptPreviewOptionValue<T extends { value: string }>(
options: readonly T[],
activePreviewOptionValue: string | null,
selectedValues: string[],
) {
const hasActivePreview = activePreviewOptionValue
? options.some((option) => option.value === activePreviewOptionValue)
: false;
if (hasActivePreview) {
return activePreviewOptionValue;
}
return options.find((option) => selectedValues.includes(option.value))?.value ?? null;
}

View File

@@ -0,0 +1,665 @@
import type { ChatConversationSummary, ChatRuntimeSnapshot } from './types';
function trimConversationRequestBadgeLabel(label: string, maxLength = 18) {
const normalized = label.replace(/\s+/g, ' ').trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1).trimEnd()}`;
}
function compactConversationBadgeLabel(label: string | null | undefined, maxWords = 2) {
const normalized = (label ?? '').replace(/\s+/g, ' ').trim();
if (!normalized) {
return null;
}
const words = normalized.split(' ').filter(Boolean);
return trimConversationRequestBadgeLabel(words.slice(0, maxWords).join(' '));
}
function normalizeConversationPromptFollowupText(text: string | null | undefined) {
const normalized = String(text ?? '')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) {
return '';
}
const followupMatch = normalized.match(
/^(?<selection>.+?)\s*\s+(?:(?:\s+?)\s+)?(?:\s+)?\s+\.?$/u,
);
const selectedText = followupMatch?.groups?.selection?.trim();
if (selectedText) {
return selectedText;
}
return normalized;
}
function isConversationPromptFollowupText(text: string | null | undefined) {
const normalized = String(text ?? '')
.replace(/\s+/g, ' ')
.trim();
return normalized.length > 0 && normalizeConversationPromptFollowupText(normalized) !== normalized;
}
function isConversationBadgeMetaRequest(text: string) {
const normalized = normalizeConversationPromptFollowupText(text);
if (!normalized) {
return false;
}
return (
/((|)?\s*||).*(|badge|)/iu.test(normalized) ||
/(|\s*|\s*|\s*||\s*|\s*)/u.test(normalized)
);
}
function normalizeConversationBadgeToken(token: string) {
return token
.replace(/^[^0-9A-Za-z-/_-]+|[^0-9A-Za-z-/_-]+$/gu, '')
.replace(/[()[\]{}"'`~!@#$%^&*+=|\\:;,.<>?…]+/g, '')
.trim();
}
function canonicalizeConversationBadgeToken(token: string) {
const normalized = normalizeConversationBadgeToken(token)
.replace(/^(|)$/iu, '')
.replace(/^(|)$/u, '')
.replace(/^(badge|)$/iu, '')
.replace(/^(api)$/iu, 'API')
.replace(/^(command|cmd|)$/iu, 'command')
.replace(/^(sql)$/iu, 'SQL')
.replace(/^(db|database)$/iu, 'DB')
.replace(/^(|ws\/chat)$/iu, '')
.replace(/^(||)$/iu, '');
if (/(codex|cdex)?\s*cli/iu.test(normalized)) {
return 'CLI';
}
if (/^release\/test$/iu.test(normalized)) {
return '설정';
}
if (/^(||)$/u.test(normalized)) {
return '세션';
}
return normalized
.replace(/(|||||||||||||)$/u, '')
.replace(/(||||||||)$/u, '')
.replace(/(|||||||||||||||||||||)$/u, '')
.trim();
}
const CONVERSATION_BADGE_ACTION_RULES = [
{ label: '재기동', pattern: /(||)/iu },
{ label: '업데이트', pattern: /(|update||)/iu },
{ label: '분리', pattern: /(|)/iu },
{ label: '수정', pattern: /(||||||||||||||)/iu },
{ label: '추가', pattern: /(|||||||)/iu },
{ label: '생성', pattern: /(|||)/iu },
{ label: '시작', pattern: /(|)/iu },
{ label: '실행', pattern: /(||||)/iu },
{ label: '확인', pattern: /(|||||)/iu },
{ label: '문의', pattern: /(||||||||\?)/iu },
] as const;
const CONVERSATION_BADGE_QUESTION_LIKE_PATTERN = /(||||||||\?)/iu;
const CONVERSATION_BADGE_STRONG_ACTION_LABELS = new Set(['재기동', '업데이트', '분리', '수정', '추가', '생성', '시작', '실행']);
function isConversationBadgeStopToken(token: string) {
return /^(||||||||||||||||||||||||||||||||||||||||||||||)$/iu.test(
token,
);
}
function collectConversationBadgeMeaningfulTokens(text: string) {
return normalizeConversationPromptFollowupText(text)
.replace(/[()[\]{}"'`~!@#$%^&*+=|\\:;,.<>?]+/g, ' ')
.split(/\s+/)
.map((token) => canonicalizeConversationBadgeToken(token))
.filter((token) => token.length > 1 && !isConversationBadgeStopToken(token));
}
function findStrongConversationBadgeActionLabel(...texts: Array<string | null | undefined>) {
for (const text of texts) {
const normalized = text?.trim();
if (!normalized) {
continue;
}
for (const rule of CONVERSATION_BADGE_ACTION_RULES) {
if (CONVERSATION_BADGE_STRONG_ACTION_LABELS.has(rule.label) && rule.pattern.test(normalized)) {
return rule.label;
}
}
}
return null;
}
function isConversationInquiryRequest(...texts: Array<string | null | undefined>) {
const hasQuestionLikeText = texts.some((text) =>
CONVERSATION_BADGE_QUESTION_LIKE_PATTERN.test(normalizeConversationPromptFollowupText(text)),
);
if (!hasQuestionLikeText) {
return false;
}
return !findStrongConversationBadgeActionLabel(...texts);
}
function findConversationBadgeActionLabel(...texts: Array<string | null | undefined>) {
const strongActionLabel = findStrongConversationBadgeActionLabel(...texts);
if (strongActionLabel) {
return strongActionLabel;
}
if (isConversationInquiryRequest(...texts)) {
return '문의';
}
for (const text of texts) {
const normalized = text?.trim();
if (!normalized) {
continue;
}
for (const rule of CONVERSATION_BADGE_ACTION_RULES) {
if (!CONVERSATION_BADGE_STRONG_ACTION_LABELS.has(rule.label) && rule.label !== '문의' && rule.pattern.test(normalized)) {
return rule.label;
}
}
}
return null;
}
function isProgramImprovementActionLabel(actionLabel: string | null) {
return actionLabel === '수정' || actionLabel === '추가' || actionLabel === '생성' || actionLabel === '분리' || actionLabel === '업데이트';
}
function isProgramImprovementRequest(...texts: Array<string | null | undefined>) {
const joinedText = texts
.map((text) => text?.trim())
.filter(Boolean)
.join(' ');
const actionLabel = findConversationBadgeActionLabel(...texts);
if (isProgramImprovementActionLabel(actionLabel)) {
return true;
}
return /(|||ui|ux).*(|||||||||||)/iu.test(joinedText);
}
function normalizeConversationBadgeTargetToken(token: string) {
if (/^(||||cbt|||||||||||preview||cli)$/iu.test(token)) {
return token.toUpperCase() === 'CLI' ? 'CLI' : token;
}
if (/^release\/test$/iu.test(token)) {
return '설정';
}
if (/^(||||||||||)$/u.test(token)) {
return '';
}
return token;
}
function findConversationBadgeTargetLabel(...texts: Array<string | null | undefined>) {
const weightedTokens = new Map<string, { score: number; firstIndex: number }>();
let tokenIndex = 0;
texts.forEach((text, sourceIndex) => {
const weight = Math.max(1, texts.length - sourceIndex);
const tokens = collectConversationBadgeMeaningfulTokens(text ?? '')
.map((token) => normalizeConversationBadgeTargetToken(token))
.filter((token) => {
if (!token) {
return false;
}
return !CONVERSATION_BADGE_ACTION_RULES.some((rule) => rule.label === token);
});
tokens.forEach((token) => {
const current = weightedTokens.get(token);
if (current) {
current.score += weight;
return;
}
weightedTokens.set(token, { score: weight, firstIndex: tokenIndex });
tokenIndex += 1;
});
});
const rankedTokens = Array.from(weightedTokens.entries())
.sort((left, right) => {
const scoreDiff = right[1].score - left[1].score;
if (scoreDiff !== 0) {
return scoreDiff;
}
return left[1].firstIndex - right[1].firstIndex;
})
.map(([token]) => token);
const strongPairRules = [
{
pattern: /(|badge|).*(|||||)|((|||||).*(|badge|))/iu,
label: '라벨 규칙',
},
{ pattern: /(\s*||).*(|badge|)/iu, label: '' },
{ pattern: /(|badge|)/iu, label: '' },
{ pattern: /(|).*(|)/iu, label: ' ' },
{ pattern: /(\s*5|\s*\s*5)/u, label: '5' },
{ pattern: /((codex|cdex)\s*cli|cli)/iu, label: 'CLI' },
{ pattern: /(||ws\/chat)/iu, label: '' },
{ pattern: /(||)/u, label: '' },
{ pattern: /(cbt)/iu, label: 'CBT' },
{ pattern: /(release\/test|)/iu, label: '' },
] as const;
const joinedText = texts
.map((text) => text?.trim())
.filter(Boolean)
.join(' ');
for (const rule of strongPairRules) {
if (rule.pattern.test(joinedText)) {
return trimConversationRequestBadgeLabel(rule.label);
}
}
if (rankedTokens.length >= 2) {
return trimConversationRequestBadgeLabel(rankedTokens.slice(0, 2).join(' '));
}
return rankedTokens[0] ? trimConversationRequestBadgeLabel(rankedTokens[0]) : null;
}
function buildConversationTaskDescriptionLabel(text: string) {
const normalizedText = normalizeConversationPromptFollowupText(text);
if (isConversationInquiryRequest(normalizedText)) {
return null;
}
const tokens = collectConversationBadgeMeaningfulTokens(normalizedText)
.map((token) => normalizeConversationBadgeTargetToken(token))
.filter((token) => {
if (!token) {
return false;
}
return !CONVERSATION_BADGE_ACTION_RULES.some((rule) => rule.label === token);
});
if (tokens.length === 0) {
return null;
}
if (/(|badge|).*(||||)|((||||).*(|badge|))/iu.test(normalizedText)) {
return '라벨 규칙';
}
const taskTypeTokens = new Set(['API', 'command', 'CLI', 'SQL', 'DB', '로그', '프록시', '소켓']);
const genericContextTokens = new Set(['응답', '호출', '요청', '설정', '연결', '에러', '오류', '문제', '상태', '흐름']);
for (let index = 0; index < tokens.length; index += 1) {
const currentToken = tokens[index];
if (!taskTypeTokens.has(currentToken)) {
continue;
}
const previousToken = tokens[index - 1];
if (previousToken && !taskTypeTokens.has(previousToken) && !genericContextTokens.has(previousToken)) {
return trimConversationRequestBadgeLabel(`${previousToken} ${currentToken}`);
}
const nextToken = tokens[index + 1];
if (nextToken && !taskTypeTokens.has(nextToken) && !genericContextTokens.has(nextToken)) {
return trimConversationRequestBadgeLabel(`${nextToken} ${currentToken}`);
}
return trimConversationRequestBadgeLabel(currentToken);
}
return compactConversationBadgeLabel(tokens.join(' '), 2);
}
type BadgeSourceItem = Pick<
ChatConversationSummary,
'title' | 'contextLabel' | 'contextDescription' | 'lastMessagePreview' | 'lastRequestPreview' | 'lastResponsePreview'
>;
function sanitizeConversationBadgeFallbackText(text: string | null | undefined) {
const normalized = normalizeConversationPromptFollowupText(text);
if (!normalized) {
return '';
}
return isConversationInquiryRequest(normalized) ? '' : normalized;
}
function buildConversationBadgeSourceTexts(item: BadgeSourceItem, runtimeSummary?: string | null) {
const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview);
const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview);
const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview);
const contextLabel = sanitizeConversationBadgeFallbackText(item.contextLabel);
const contextDescription = sanitizeConversationBadgeFallbackText(item.contextDescription);
const titleText = sanitizeConversationBadgeFallbackText(item.title);
const runtimeText = normalizeConversationPromptFollowupText(runtimeSummary);
const isMetaRequest = isConversationBadgeMetaRequest(requestText);
const isInquiryRequest = isConversationInquiryRequest(requestText);
if (isMetaRequest || isInquiryRequest) {
return [runtimeText, responseText, messageText, contextDescription, contextLabel, titleText];
}
return [runtimeText, responseText, requestText, messageText, contextDescription, contextLabel, titleText];
}
function inferConversationRequestBadgeLabel(
item: Pick<
ChatConversationSummary,
| 'title'
| 'requestBadgeLabel'
| 'contextLabel'
| 'contextDescription'
| 'lastMessagePreview'
| 'lastRequestPreview'
| 'lastResponsePreview'
>,
runtimeSummary?: string | null,
) {
const labelSourceTexts = buildConversationBadgeSourceTexts(item, runtimeSummary);
const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview);
const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview);
const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview);
const contextLabel = sanitizeConversationBadgeFallbackText(item.contextLabel);
const contextDescription = sanitizeConversationBadgeFallbackText(item.contextDescription);
const titleText = sanitizeConversationBadgeFallbackText(item.title);
const requestActionLabel = findConversationBadgeActionLabel(requestText);
const actionLabel = findConversationBadgeActionLabel(...labelSourceTexts);
const targetLabel = findConversationBadgeTargetLabel(...labelSourceTexts);
const taskDescriptionLabel = buildConversationTaskDescriptionLabel(requestText);
if (taskDescriptionLabel && requestActionLabel && isConversationPromptFollowupText(item.lastRequestPreview)) {
return trimConversationRequestBadgeLabel(`${taskDescriptionLabel} ${requestActionLabel}`);
}
if (taskDescriptionLabel && actionLabel && isProgramImprovementActionLabel(actionLabel)) {
return trimConversationRequestBadgeLabel(`${taskDescriptionLabel} ${actionLabel}`);
}
if (targetLabel && actionLabel) {
if (targetLabel === '라벨 규칙') {
return trimConversationRequestBadgeLabel(`${targetLabel} ${actionLabel}`);
}
const targetWords = targetLabel.split(/\s+/).filter(Boolean);
if (targetWords.includes(actionLabel)) {
return trimConversationRequestBadgeLabel(targetLabel);
}
const primaryTarget = targetWords[targetWords.length - 1] || targetLabel;
return trimConversationRequestBadgeLabel(`${primaryTarget} ${actionLabel}`);
}
if (targetLabel) {
return trimConversationRequestBadgeLabel(targetLabel);
}
if (actionLabel) {
const fallbackLabel = compactConversationBadgeLabel(
contextDescription || titleText || contextLabel || responseText || messageText,
1,
);
return fallbackLabel ? trimConversationRequestBadgeLabel(`${fallbackLabel} ${actionLabel}`) : actionLabel;
}
const fallbackLabel = compactConversationBadgeLabel(
contextDescription || titleText || contextLabel || responseText || messageText,
2,
);
if (fallbackLabel) {
return fallbackLabel;
}
return isConversationInquiryRequest(requestText) ? '문의' : compactConversationBadgeLabel(requestText, 2);
}
export function resolveConversationRequestBadgeLabelForUserText(args: {
requestText: string;
currentMenuLabel?: string | null;
title?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
}) {
const normalizedRequestText = normalizeConversationPromptFollowupText(args.requestText);
if (!normalizedRequestText) {
return null;
}
const requestActionLabel = findConversationBadgeActionLabel(normalizedRequestText);
const taskDescriptionLabel = buildConversationTaskDescriptionLabel(normalizedRequestText);
if (taskDescriptionLabel && requestActionLabel && isConversationPromptFollowupText(args.requestText)) {
return trimConversationRequestBadgeLabel(`${taskDescriptionLabel} ${requestActionLabel}`);
}
if (args.currentMenuLabel && isProgramImprovementRequest(normalizedRequestText)) {
return trimConversationRequestBadgeLabel(args.currentMenuLabel);
}
if (isConversationInquiryRequest(normalizedRequestText)) {
const fallbackLabel = compactConversationBadgeLabel(
sanitizeConversationBadgeFallbackText(args.contextDescription) ||
sanitizeConversationBadgeFallbackText(args.contextLabel) ||
sanitizeConversationBadgeFallbackText(args.title) ||
args.currentMenuLabel?.trim() ||
'',
2,
);
return fallbackLabel || '문의';
}
if (taskDescriptionLabel) {
return taskDescriptionLabel;
}
return inferConversationRequestBadgeLabel({
title: args.title?.trim() || '',
requestBadgeLabel: null,
contextLabel: args.contextLabel?.trim() || '',
contextDescription: args.contextDescription?.trim() || '',
lastMessagePreview: '',
lastRequestPreview: normalizedRequestText,
lastResponsePreview: '',
});
}
export function resolveConversationTitleForUserText(args: {
requestText: string;
fallbackTitle?: string | null;
}) {
const normalizedRequestText = normalizeConversationPromptFollowupText(args.requestText);
const fallbackTitle = args.fallbackTitle?.trim() || '';
if (!normalizedRequestText) {
return fallbackTitle || '새 대화';
}
const requestActionLabel = findConversationBadgeActionLabel(normalizedRequestText);
const taskDescriptionLabel = buildConversationTaskDescriptionLabel(normalizedRequestText);
const targetLabel = findConversationBadgeTargetLabel(normalizedRequestText);
if (taskDescriptionLabel && requestActionLabel && isConversationPromptFollowupText(args.requestText)) {
return trimConversationRequestBadgeLabel(`${taskDescriptionLabel} ${requestActionLabel}`);
}
if (taskDescriptionLabel && requestActionLabel && isProgramImprovementActionLabel(requestActionLabel)) {
return trimConversationRequestBadgeLabel(`${taskDescriptionLabel} ${requestActionLabel}`);
}
if (!taskDescriptionLabel && requestActionLabel) {
const actionSuffixPattern = new RegExp(`^(?<subject>.+?)(?:으로|로)?\\s+${requestActionLabel}$`, 'u');
const subjectText = normalizedRequestText.match(actionSuffixPattern)?.groups?.subject?.trim() || '';
const subjectLabel = compactConversationBadgeLabel(subjectText, 2);
if (subjectLabel) {
return trimConversationRequestBadgeLabel(`${subjectLabel} ${requestActionLabel}`);
}
}
if (targetLabel && requestActionLabel) {
const targetWords = targetLabel.split(/\s+/).filter(Boolean);
if (targetWords.includes(requestActionLabel)) {
return trimConversationRequestBadgeLabel(targetLabel);
}
const primaryTarget = targetWords[targetWords.length - 1] || targetLabel;
return trimConversationRequestBadgeLabel(`${primaryTarget} ${requestActionLabel}`);
}
if (taskDescriptionLabel) {
return taskDescriptionLabel;
}
if (targetLabel) {
return trimConversationRequestBadgeLabel(targetLabel);
}
return compactConversationBadgeLabel(normalizedRequestText, 2) || fallbackTitle || '새 대화';
}
const TOP_MENU_BADGE_LABELS: Record<string, string> = {
docs: 'Docs',
apis: 'API',
plans: '작업',
chat: 'Codex Live',
play: 'Play',
};
function resolvePageTitleSegments(pageTitle?: string | null) {
const segments = String(pageTitle ?? '')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean);
return segments.filter((segment, index) => segments.indexOf(segment) === index);
}
export function resolveConversationScreenTitle(pageTitle?: string | null, topMenu?: string | null) {
const uniqueSegments = resolvePageTitleSegments(pageTitle);
const pageLabel = uniqueSegments.join(' / ').trim();
if (pageLabel) {
return pageLabel;
}
const topMenuLabel = TOP_MENU_BADGE_LABELS[String(topMenu ?? '').trim()];
return topMenuLabel?.trim() || null;
}
export function resolveCurrentMenuRequestLabel(pageTitle?: string | null, topMenu?: string | null) {
const uniqueSegments = resolvePageTitleSegments(pageTitle);
const preferredPageLabel = uniqueSegments.at(-1) ?? uniqueSegments[0] ?? '';
if (preferredPageLabel) {
return trimConversationRequestBadgeLabel(preferredPageLabel);
}
const topMenuLabel = TOP_MENU_BADGE_LABELS[String(topMenu ?? '').trim()];
return topMenuLabel ? trimConversationRequestBadgeLabel(topMenuLabel) : null;
}
export function toRuntimeStatusTime(value: string | null | undefined) {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function resolveRuntimeItemStatusTime(
item:
| ChatRuntimeSnapshot['running'][number]
| ChatRuntimeSnapshot['queued'][number]
| ChatRuntimeSnapshot['recent'][number],
) {
if ('lastUpdatedAt' in item) {
return toRuntimeStatusTime(item.lastUpdatedAt);
}
if (item.status === 'running') {
return toRuntimeStatusTime(item.startedAt ?? item.enqueuedAt);
}
return toRuntimeStatusTime(item.enqueuedAt);
}
export function buildConversationRequestBadgeLabel(
item: Pick<
ChatConversationSummary,
| 'sessionId'
| 'title'
| 'requestBadgeLabel'
| 'currentJobStatus'
| 'contextLabel'
| 'contextDescription'
| 'lastMessagePreview'
| 'lastRequestPreview'
| 'lastResponsePreview'
>,
runtimeSnapshot: ChatRuntimeSnapshot | null,
) {
const explicitLabel = trimConversationRequestBadgeLabel(item.requestBadgeLabel ?? '');
if (explicitLabel) {
return explicitLabel;
}
if (!runtimeSnapshot) {
return inferConversationRequestBadgeLabel(item);
}
const latestRuntimeItem = [...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent]
.filter((runtimeItem) => runtimeItem.sessionId === item.sessionId)
.sort((left, right) => resolveRuntimeItemStatusTime(right) - resolveRuntimeItemStatusTime(left))[0];
const runtimeSummary = latestRuntimeItem?.summary?.trim() || '';
const inferredLabel = inferConversationRequestBadgeLabel(item, runtimeSummary);
if (item.currentJobStatus === 'queued' || item.currentJobStatus === 'started') {
return inferredLabel || '작업중';
}
if (inferredLabel) {
return inferredLabel;
}
return runtimeSummary ? compactConversationBadgeLabel(runtimeSummary, 3) : null;
}

View File

@@ -113,6 +113,9 @@
}
.app-chat-panel__system-status-slot--bottom {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 12px 8px;
}
@@ -129,11 +132,45 @@
transition: opacity 140ms ease;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) {
gap: 10px;
min-height: 30px;
padding: 7px 10px;
}
.app-chat-panel__system-status-label {
flex: 0 0 auto;
min-width: 30px;
font-size: 10px;
font-weight: 700;
color: rgba(15, 23, 42, 0.62);
letter-spacing: 0.04em;
}
.app-chat-panel__system-status .ant-typography {
margin: 0;
font-size: 11px;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) .app-chat-panel__system-status-label {
min-width: 34px;
font-size: 11px;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) .app-chat-panel__system-status-summary-inline {
font-size: 13px;
line-height: 1.5;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) .app-chat-panel__system-status-records-toggle.ant-btn {
margin-left: auto;
font-size: 12px;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) .app-chat-panel__system-status-records-actions {
margin-left: auto;
}
.app-chat-panel__system-status-dots {
display: inline-flex;
align-items: center;
@@ -157,6 +194,547 @@
animation-delay: 0.4s;
}
.app-chat-panel__system-status--records {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
min-height: 0;
max-height: min(42vh, 360px);
padding: 8px 10px;
}
.app-chat-panel__system-status-records-header {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: nowrap;
}
.app-chat-panel__system-status-records-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
min-width: 0;
flex: 1 1 auto;
}
.app-chat-panel__system-status-records-count {
font-size: 12px;
font-weight: 700;
color: #0f172a;
}
.app-chat-panel__system-status-records-meta {
min-width: 0;
font-size: 11px;
color: #475569;
overflow-wrap: anywhere;
word-break: break-word;
}
.app-chat-panel__system-status-summary-inline {
min-width: 0;
flex: 1 1 auto;
font-size: 11px;
line-height: 1.45;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-chat-panel__system-status-records-toggle.ant-btn {
flex: 0 0 auto;
padding-inline: 6px;
color: #1d4ed8;
}
.app-chat-panel__system-status-records-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
margin-left: auto;
flex-wrap: nowrap;
}
.app-chat-panel__system-status-records-toggle--icon-only.ant-btn {
width: 30px;
min-width: 30px;
height: 30px;
padding-inline: 0;
}
.app-chat-panel__system-status-filter-toggle.ant-btn {
color: #64748b;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 999px;
background: rgba(241, 245, 249, 0.9);
}
.app-chat-panel__system-status-filter-toggle.ant-btn:hover,
.app-chat-panel__system-status-filter-toggle.ant-btn:focus-visible {
color: #1d4ed8;
border-color: rgba(96, 165, 250, 0.4);
background: rgba(239, 246, 255, 0.96);
}
.app-chat-panel__system-status-filter-toggle--active.ant-btn {
color: #1d4ed8;
border-color: rgba(96, 165, 250, 0.52);
background: rgba(219, 234, 254, 0.96);
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.16);
}
.app-chat-panel__system-status-records-empty {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 2px 2px 0;
font-size: 12px;
color: #64748b;
}
.app-chat-panel__system-status-records-body {
display: flex;
flex: 1 1 auto;
width: 100%;
min-height: 0;
flex-direction: column;
gap: 8px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.app-chat-panel__system-execution-record {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
align-items: flex-start;
gap: 8px;
padding: 8px 9px;
border: 1px solid rgba(191, 219, 254, 0.6);
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
}
.app-chat-panel__system-execution-record--child {
position: relative;
width: calc(100% - 18px);
margin-left: calc(18px * min(var(--system-execution-indent-level, 1), 3));
padding-left: 12px;
border-color: rgba(147, 197, 253, 0.92);
border-left-width: 4px;
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98), rgba(248, 250, 252, 0.94));
box-shadow: inset 0 0 0 1px rgba(191, 219, 254, 0.24);
}
.app-chat-panel__system-execution-record--child::before {
content: '';
position: absolute;
top: 16px;
left: -13px;
width: 10px;
height: calc(100% - 32px);
border-top: 2px solid rgba(96, 165, 250, 0.9);
border-left: 2px solid rgba(96, 165, 250, 0.9);
border-top-left-radius: 8px;
pointer-events: none;
}
.app-chat-panel__system-execution-record-hierarchy {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
color: #1d4ed8;
}
.app-chat-panel__system-execution-record-hierarchy::before {
content: '└';
font-size: 13px;
line-height: 1;
color: #60a5fa;
}
.app-chat-panel__system-execution-record--collapsed {
border-color: rgba(148, 163, 184, 0.32);
}
.app-chat-panel__system-execution-record-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
padding: 0;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.app-chat-panel__system-execution-record-row {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.app-chat-panel__system-execution-record-badges {
display: inline-flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.app-chat-panel__system-execution-record-status {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
line-height: 1.25;
white-space: nowrap;
}
.app-chat-panel__system-execution-record-status-text--compact {
display: none;
}
.app-chat-panel__system-execution-record-status--mobile-summary {
display: none;
}
.app-chat-panel__system-execution-record-status--accepted,
.app-chat-panel__system-execution-record-status--queued {
color: #1d4ed8;
background: rgba(191, 219, 254, 0.72);
}
.app-chat-panel__system-execution-record-status--started {
color: #1e3a8a;
background: rgba(191, 219, 254, 0.92);
}
.app-chat-panel__system-execution-record-status--completed {
color: #15803d;
background: rgba(220, 252, 231, 0.92);
}
.app-chat-panel__system-execution-record-status--failed,
.app-chat-panel__system-execution-record-status--cancelled {
color: #b91c1c;
background: rgba(254, 226, 226, 0.92);
}
.app-chat-panel__system-execution-record-status--neutral {
color: #475569;
background: rgba(226, 232, 240, 0.92);
}
.app-chat-panel__system-execution-record-status--prompt {
color: #7c2d12;
background: rgba(254, 215, 170, 0.92);
}
.app-chat-panel__system-execution-record-status--unread {
color: #9a3412;
background: rgba(254, 215, 170, 0.98);
}
.app-chat-panel__system-execution-record-time {
flex: 0 0 auto;
font-size: 11px;
color: #64748b;
}
.app-chat-panel__system-execution-record-text {
font-size: 13px;
font-weight: 600;
line-height: 1.5;
color: #0f172a;
overflow-wrap: anywhere;
word-break: break-word;
}
.app-chat-panel__system-execution-record-detail {
font-size: 12px;
line-height: 1.45;
color: #475569;
overflow-wrap: anywhere;
word-break: break-word;
}
.app-chat-panel__system-execution-record-actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
}
.app-chat-panel__system-execution-record-action.ant-btn {
color: #1e3a8a;
}
@media (max-width: 767px) {
.app-chat-panel__system-status-slot--bottom {
gap: 8px;
padding: 0 10px 10px;
}
.app-chat-panel__system-status--records {
gap: 10px;
padding: 10px 11px;
max-height: min(46vh, 400px);
}
.app-chat-panel__system-status-records-header {
align-items: center;
gap: 10px;
}
.app-chat-panel__system-status-records-heading {
gap: 7px 8px;
}
.app-chat-panel__system-status-records-actions {
justify-content: flex-end;
gap: 6px;
}
.app-chat-panel__system-status-records-toggle.ant-btn {
min-height: 32px;
padding-inline: 10px;
}
.app-chat-panel__system-status-records-toggle--icon-only.ant-btn {
width: 32px;
min-width: 32px;
padding-inline: 0;
}
.app-chat-panel__system-execution-record {
gap: 8px;
padding: 9px 10px;
border-radius: 14px;
}
.app-chat-panel__system-execution-record--child {
width: calc(100% - 14px);
margin-left: calc(14px * min(var(--system-execution-indent-level, 1), 2));
padding-left: 10px;
}
.app-chat-panel__system-execution-record--child::before {
top: 14px;
left: -11px;
width: 8px;
height: calc(100% - 28px);
}
.app-chat-panel__system-execution-record-hierarchy {
font-size: 10px;
}
.app-chat-panel__system-execution-record-main {
gap: 6px;
}
.app-chat-panel__system-execution-record-row {
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
}
.app-chat-panel__system-execution-record-badges {
flex-wrap: wrap;
gap: 4px;
}
.app-chat-panel__system-execution-record-status {
max-width: 100%;
padding: 3px 8px;
font-size: 10px;
}
.app-chat-panel__system-execution-record-status--desktop-only {
display: none;
}
.app-chat-panel__system-execution-record-status--mobile-summary {
display: inline-flex;
min-width: 0;
max-width: min(100%, 112px);
}
.app-chat-panel__system-execution-record-status--mobile-summary .app-chat-panel__system-execution-record-status-text--compact {
overflow: hidden;
text-overflow: ellipsis;
}
.app-chat-panel__system-execution-record-status-text--full {
display: none;
}
.app-chat-panel__system-execution-record-status-text--compact {
display: inline;
}
.app-chat-panel__system-execution-record-time {
padding-top: 0;
font-size: 10px;
letter-spacing: -0.01em;
white-space: nowrap;
}
.app-chat-panel__system-execution-record-text {
line-height: 1.55;
}
.app-chat-panel__system-execution-record-detail {
line-height: 1.5;
}
.app-chat-panel__system-execution-record-actions {
align-self: flex-start;
padding-top: 1px;
}
.app-chat-panel__system-execution-record-action.ant-btn {
width: 30px;
min-width: 30px;
height: 30px;
padding: 0;
}
.app-chat-panel__system-status-summary-inline {
font-size: 11px;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) {
gap: 8px;
min-height: 28px;
padding: 6px 8px;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) .app-chat-panel__system-status-label {
min-width: 32px;
font-size: 10px;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) .app-chat-panel__system-status-summary-inline {
font-size: 12px;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) .app-chat-panel__system-status-records-toggle.ant-btn {
margin-left: auto;
font-size: 11px;
}
.app-chat-panel__system-status:not(.app-chat-panel__system-status--records) .app-chat-panel__system-status-records-actions {
margin-left: auto;
}
}
.app-chat-panel__system-records {
display: flex;
flex-direction: column;
gap: 8px;
}
.app-chat-panel__system-record {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 14px;
background: rgba(248, 250, 252, 0.82);
}
.app-chat-panel__system-record-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.app-chat-panel__system-record-title-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.app-chat-panel__system-record-label {
font-size: 12px;
font-weight: 700;
color: #0f172a;
}
.app-chat-panel__system-record-status,
.app-chat-panel__system-record-time {
font-size: 12px;
line-height: 1.4;
color: #475569;
}
.app-chat-panel__system-record-id {
min-width: 0;
max-width: 44%;
font-size: 11px;
line-height: 1.4;
color: #64748b;
text-align: right;
word-break: break-all;
}
.app-chat-panel__system-record-request {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #0f172a;
font-weight: 600;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
@media (max-width: 767px) {
.app-chat-panel__system-record {
gap: 10px;
padding: 11px;
}
.app-chat-panel__system-record-label,
.app-chat-panel__system-record-status,
.app-chat-panel__system-record-time {
font-size: 11px;
}
.app-chat-panel__system-record-id {
font-size: 10px;
}
.app-chat-panel__system-record-request {
font-size: 12px;
}
}
.app-chat-message {
--app-chat-message-fade-end: rgba(248, 250, 252, 0.96);
gap: 4px;
@@ -315,7 +893,7 @@
margin: 0;
width: 100%;
max-width: 100%;
font-size: 12px;
font-size: 13px;
line-height: 1.45;
overflow-wrap: anywhere;
word-break: break-word;
@@ -666,6 +1244,14 @@
line-height: 1.45;
}
.app-chat-prompt-card__option-preview-hint {
display: inline-flex;
margin-top: 2px;
color: #2563eb;
font-size: 11px;
font-weight: 600;
}
.app-chat-prompt-card__preview-shell {
display: flex;
flex-direction: column;
@@ -675,6 +1261,38 @@
background: rgba(241, 245, 249, 0.8);
}
.app-chat-prompt-card__preview-tabs-shell {
display: flex;
flex-direction: column;
gap: 10px;
}
.app-chat-prompt-card__preview-tabs.ant-tabs {
margin-bottom: 0;
}
.app-chat-prompt-card__preview-tabs .ant-tabs-nav {
margin: 0;
}
.app-chat-prompt-card__preview-tabs .ant-tabs-nav::before {
border-bottom-color: rgba(148, 163, 184, 0.22);
}
.app-chat-prompt-card__preview-tabs .ant-tabs-tab {
padding: 6px 0 10px;
color: #64748b;
}
.app-chat-prompt-card__preview-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: #1d4ed8;
font-weight: 700;
}
.app-chat-prompt-card__preview-tabs .ant-tabs-ink-bar {
background: #2563eb;
}
.app-chat-prompt-card__stepper {
margin-bottom: 12px;
}
@@ -940,53 +1558,17 @@
}
.app-chat-prompt-card__preview-modal.ant-modal {
top: 0;
max-width: 100vw;
margin: 0;
padding: 0;
}
.app-chat-prompt-card__preview-modal .ant-modal-content {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
padding: 0;
overflow: hidden;
border-radius: 0;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(226, 232, 240, 0.98)),
radial-gradient(circle at top, rgba(13, 148, 136, 0.12), transparent 34%);
}
.app-chat-prompt-card__preview-modal .ant-modal-close {
top: 12px;
right: 12px;
width: 36px;
height: 36px;
border-radius: 999px;
color: #e2e8f0;
background: rgba(15, 23, 42, 0.76);
}
.app-chat-prompt-card__preview-modal .ant-modal-close:hover {
color: #fff;
background: rgba(15, 23, 42, 0.92);
}
.app-chat-prompt-card__preview-modal .ant-modal-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
padding: 0;
}
.app-chat-prompt-card__preview-modal-surface {
display: flex;
flex: 1 1 auto;
min-height: 0;
width: 100%;
padding: 52px 0 0;
padding: 0;
}
.app-chat-prompt-card__preview-modal .app-chat-prompt-card__preview-frame {
@@ -1010,6 +1592,51 @@
height: 100%;
max-height: none;
padding: 20px 18px 18px;
background: #f8fafc;
}
.app-chat-prompt-card__preview-modal .app-chat-prompt-card__preview-markdown .markdown-preview,
.app-chat-prompt-card__preview-modal .app-chat-prompt-card__preview-markdown code {
color: #0f172a;
}
.app-chat-prompt-card__preview-code {
display: flex;
flex: 1 1 auto;
min-height: 0;
width: 100%;
}
.app-chat-prompt-card__preview-code .previewer-ui__editor,
.app-chat-prompt-card__preview-code .previewer-ui__editor-body {
height: 100%;
width: 100%;
}
.app-chat-prompt-card__preview-zoom-stage {
width: 100%;
height: 100%;
background: #0b1220;
}
.app-chat-prompt-card__preview-zoom-content {
display: flex;
width: 100%;
height: 100%;
}
.app-chat-prompt-card__preview-zoom-content .app-chat-prompt-card__preview-image,
.app-chat-prompt-card__preview-zoom-content .app-chat-prompt-card__preview-frame {
width: 100%;
height: 100%;
min-height: 100%;
max-height: none;
object-fit: contain;
}
.app-chat-prompt-card__preview-zoom-content .app-chat-prompt-card__preview-frame {
pointer-events: none;
background: #fff;
}
.app-chat-prompt-card__preview-modal .app-chat-prompt-card__preview-placeholder {

View File

@@ -2,6 +2,9 @@
display: flex;
flex-direction: column;
align-self: stretch;
width: 100%;
min-width: 0;
max-width: 100%;
height: 100%;
max-height: none;
min-height: 0;
@@ -114,6 +117,9 @@
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
width: 100%;
max-width: 100%;
height: auto;
padding: 6px;
overflow: hidden;
@@ -145,9 +151,11 @@
position: relative;
display: flex;
flex-direction: row;
flex: 1;
flex: 1 1 0%;
min-height: 0;
min-width: 0;
width: 100%;
max-width: 100%;
border-radius: 22px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.84);
@@ -176,6 +184,14 @@
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
.app-chat-panel__conversation-list-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 1 1 auto;
min-width: 0;
}
.app-chat-panel__conversation-list-search {
padding: 8px 8px 0;
}
@@ -256,6 +272,23 @@
gap: 6px;
}
.app-chat-panel__conversation-section-empty {
padding: 4px 0 6px;
}
.app-chat-panel__conversation-section-empty .ant-empty {
margin: 0;
padding: 18px 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12);
}
.app-chat-panel__conversation-section-empty .ant-empty-description {
color: #64748b;
font-size: 12px;
}
.app-chat-panel__conversation-section-header {
display: flex;
align-items: center;
@@ -316,6 +349,16 @@
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.16);
}
.app-chat-panel__conversation-section-header--work .app-chat-panel__conversation-section-title {
color: #0f766e;
}
.app-chat-panel__conversation-section-header--work .app-chat-panel__conversation-section-count {
background: linear-gradient(180deg, rgba(204, 251, 241, 0.98), rgba(153, 246, 228, 0.96));
color: #115e59;
box-shadow: 0 4px 12px rgba(13, 148, 136, 0.14);
}
.app-chat-panel__conversation-section-title {
font-size: 11px;
font-weight: 800;
@@ -460,6 +503,11 @@
color: #1d4ed8;
}
.app-chat-panel__conversation-section-toggle--work .app-chat-panel__conversation-section-toggle-icon,
.app-chat-panel__conversation-section-toggle--work .app-chat-panel__conversation-section-title {
color: #0f766e;
}
.app-chat-panel__conversation-item {
position: relative;
display: flex;
@@ -637,9 +685,29 @@
gap: 6px;
min-width: 0;
flex: 1;
overflow: hidden;
border-radius: 10px;
}
.app-chat-panel__conversation-item-title-badge,
.app-chat-panel__conversation-title-badge {
display: inline-flex;
align-items: center;
max-width: 110px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(204, 251, 241, 0.96);
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.16);
color: #0f766e;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: none;
}
.app-chat-panel__conversation-item-title,
.app-chat-panel__conversation-item-id,
.app-chat-panel__conversation-item-preview,
@@ -810,23 +878,34 @@
flex-direction: column;
align-self: stretch;
justify-content: center;
gap: 2px;
padding: 4px 4px 4px 0;
gap: 4px;
padding: 6px 6px 6px 0;
}
.app-chat-panel__conversation-item-actions--recent {
justify-content: flex-start;
}
.app-chat-panel__conversation-item-folder.ant-btn,
.app-chat-panel__conversation-item-delete.ant-btn {
flex-shrink: 0;
width: 28px;
min-width: 28px;
height: 28px;
width: 32px;
min-width: 32px;
height: 32px;
color: #94a3b8;
border-radius: 10px;
}
.app-chat-panel__conversation-item-delete.ant-btn {
margin-right: 0;
}
.app-chat-panel__conversation-item-delete.ant-btn:hover,
.app-chat-panel__conversation-item-delete.ant-btn:focus-visible {
color: #dc2626;
background: rgba(254, 226, 226, 0.9);
}
.app-chat-panel__general-section-modal {
display: flex;
flex-direction: column;
@@ -892,11 +971,7 @@
overflow: visible;
}
.app-chat-preview-card--activity-collapsed {
margin-bottom: 0;
}
.app-chat-preview-card--activity-expanded {
.app-chat-preview-card--activity {
margin-bottom: 0;
}
@@ -918,14 +993,6 @@
max-width: 100%;
}
.app-chat-preview-card--activity-collapsed .app-chat-preview-card__body--activity {
padding-top: 2px;
}
.app-chat-preview-card--activity-expanded .app-chat-preview-card__body--activity:last-child {
padding-top: 8px;
}
.app-chat-preview-card__glyph--activity {
color: #1d4ed8;
background: linear-gradient(180deg, rgba(191, 219, 254, 0.98), rgba(219, 234, 254, 0.94));
@@ -970,14 +1037,6 @@
border: 1px solid rgba(191, 219, 254, 0.52);
}
.app-chat-activity-card__summary-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 10px;
width: 100%;
min-width: 0;
}
.app-chat-activity-card__summary-label.ant-typography {
margin: 0;
font-size: 11px;
@@ -1003,19 +1062,6 @@
align-items: flex-start;
}
.app-chat-preview-card--activity .app-chat-preview-card__toggle.ant-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
height: auto;
min-width: fit-content;
padding: 0;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
}
.app-chat-activity-checklist-stack {
display: grid;
gap: 10px;
@@ -1174,7 +1220,7 @@
gap: 8px;
width: 100%;
min-width: 0;
max-height: min(40vh, 360px);
max-height: min(36vh, 320px);
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 14px;
@@ -1271,10 +1317,6 @@
}
@media (max-width: 768px) {
.app-chat-activity-card__summary-grid {
grid-template-columns: minmax(0, 1fr);
}
.app-chat-activity-checklist__header,
.app-chat-activity-checklist__row {
align-items: flex-start;
@@ -1377,18 +1419,31 @@
.app-chat-panel__title-heading {
display: flex;
align-items: center;
align-items: flex-start;
gap: 6px;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
}
.app-chat-panel__title-heading-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 0;
}
.app-chat-panel__title-heading .ant-typography {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-chat-panel__title-heading-copy .ant-typography {
width: 100%;
}
.app-chat-panel__title-cluster {
position: relative;
display: inline-flex;
@@ -1563,7 +1618,7 @@
}
.app-chat-panel .app-chat-message__body.ant-typography {
font-size: 17px !important;
font-size: 11px !important;
line-height: 1.65;
}
@@ -1649,7 +1704,7 @@
.app-chat-panel .app-chat-message__body,
.app-chat-panel .app-chat-message__body.ant-typography {
font-size: 20px !important;
font-size: 14px !important;
line-height: 1.6;
}
@@ -1666,27 +1721,79 @@
}
@media (min-width: 820px) and (max-width: 1366px) {
.app-chat-panel .app-chat-panel__conversation-section-title,
.app-chat-panel .app-chat-panel__conversation-section-count,
.app-chat-panel .app-chat-panel__conversation-item-time,
.app-chat-panel .app-chat-panel__conversation-item-id,
.app-chat-panel .app-chat-panel__conversation-item-status,
.app-chat-panel .app-chat-panel__conversation-item-flag,
.app-chat-panel .app-chat-panel__conversation-item-unread-badge {
font-size: 11px;
}
.app-chat-panel .app-chat-panel__conversation-item-title {
font-size: 11px;
}
.app-chat-panel .app-chat-panel__conversation-item-preview {
font-size: 12px;
}
.app-chat-panel .app-chat-panel__composer .ant-input-textarea textarea,
.app-chat-panel .app-chat-panel__composer textarea.ant-input {
font-size: 16px;
}
.app-chat-panel--ipad-readable .app-chat-message__body,
.app-chat-panel--ipad-readable .app-chat-message__body.ant-typography,
.app-chat-panel--ipad-readable .app-chat-message__block,
.app-chat-panel--ipad-readable .app-chat-message__block .ant-typography,
.app-chat-panel--ipad-readable .app-chat-message__block span,
.app-chat-panel--ipad-readable .app-chat-message__block a {
font-size: 22px !important;
font-size: 15px !important;
line-height: 1.6;
}
.app-chat-panel--ipad-readable .app-chat-message__header .ant-typography,
.app-chat-panel--ipad-readable .app-chat-panel__composer .ant-input-textarea textarea,
.app-chat-panel--ipad-readable .app-chat-panel__composer textarea.ant-input {
font-size: 16px;
}
.app-chat-panel--ipad-readable .app-chat-panel__conversation-section-title,
.app-chat-panel--ipad-readable .app-chat-panel__conversation-item-time,
.app-chat-panel--ipad-readable .app-chat-panel__conversation-item-id,
.app-chat-panel--ipad-readable .app-chat-panel__conversation-item-status,
.app-chat-panel--ipad-readable .app-chat-panel__conversation-item-flag,
.app-chat-panel--ipad-readable .app-chat-panel__conversation-item-unread-badge,
.app-chat-panel--ipad-readable .app-chat-panel__history-loader,
.app-chat-panel--ipad-readable .app-chat-panel__system-status .ant-typography,
.app-chat-panel--ipad-readable .app-chat-message__header-meta .ant-typography,
.app-chat-panel--ipad-readable .app-chat-message__status,
.app-chat-panel--ipad-readable .app-chat-message__request-detail,
.app-chat-panel--ipad-readable .app-chat-preview-card__kind,
.app-chat-panel--ipad-readable .app-chat-preview-card__kind.ant-typography,
.app-chat-panel--ipad-readable .app-chat-panel__composer-queue-order,
.app-chat-panel--ipad-readable .app-chat-panel__composer-attachment-pending-label,
.app-chat-panel--ipad-readable .app-chat-panel__resource-chip,
.app-chat-panel--ipad-readable .app-chat-panel__resource-strip-filter,
.app-chat-panel--ipad-readable .app-chat-panel__resource-strip-empty.ant-typography,
.app-chat-panel--ipad-readable .app-chat-panel__busy-overlay span {
font-size: 14px;
}
}
@media (min-width: 768px) and (pointer: fine) {
.app-chat-panel .app-chat-message__body,
.app-chat-panel .app-chat-message__body.ant-typography {
font-size: 19px !important;
font-size: 13px !important;
}
}
@media (min-width: 768px) and (max-width: 1366px) and (pointer: fine) {
.app-chat-panel .app-chat-message__body,
.app-chat-panel .app-chat-message__body.ant-typography {
font-size: 22px !important;
font-size: 16px !important;
}
}

View File

@@ -95,22 +95,41 @@
box-sizing: border-box;
}
.app-chat-preview-card--fullscreen {
.app-chat-preview-overlay {
position: fixed;
inset: 0;
z-index: 1400;
width: 100vw;
height: 100vh;
height: 100dvh;
padding: 0;
margin: 0;
background: rgba(15, 23, 42, 0.28);
}
.app-chat-preview-card--fullscreen {
position: absolute;
inset: 0;
z-index: 1;
width: 100vw;
min-width: 100vw;
max-width: 100vw;
height: 100vh;
height: 100dvh;
min-height: 100vh;
min-height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
margin: 0 !important;
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
border: 0;
border-radius: 0;
background: #f8fafc;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.26);
overflow: hidden;
}
.app-chat-preview-card--fullscreen .app-chat-preview-card__header {
@@ -123,54 +142,89 @@
}
.app-chat-preview-card--fullscreen .app-chat-preview-card__body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
width: 100vw;
max-width: 100vw;
width: 100%;
max-width: 100%;
padding-top: 0;
border-top: 0;
overflow: hidden;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich {
width: 100vw;
max-width: 100vw;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
width: 100%;
max-width: 100%;
}
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich,
.app-chat-preview-card--fullscreen .codex-diff-previewer,
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list,
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section,
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-body,
.app-chat-preview-card--fullscreen .previewer-ui,
.app-chat-preview-card--fullscreen .previewer-ui__editor,
.app-chat-preview-card--fullscreen .previewer-ui__editor-body {
height: 100%;
.app-chat-preview-card--fullscreen .previewer-ui {
min-height: 0;
width: 100%;
max-width: none;
}
.app-chat-preview-card--fullscreen .codex-diff-previewer,
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list {
gap: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-toolbar {
display: flex;
padding: 12px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(241, 245, 249, 0.96);
}
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section {
flex: 0 0 auto;
border-width: 0 0 1px;
border-radius: 0;
height: auto;
min-height: auto;
}
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-toggle {
padding-inline: 16px 88px;
}
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-body {
height: auto;
overflow: visible;
}
.app-chat-preview-card--fullscreen .previewer-ui__editor {
height: auto;
min-height: 0;
border-width: 0;
border-radius: 0;
}
.app-chat-preview-card--fullscreen .previewer-ui__editor-body {
height: auto;
max-height: none;
padding-inline: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.app-chat-panel__preview-rich {
@@ -212,6 +266,10 @@
display: none;
}
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich .codex-diff-previewer__diff-toolbar {
display: flex;
}
.app-chat-panel__preview-rich .codex-diff-previewer__diff-section {
border-radius: 0;
}
@@ -554,6 +612,24 @@
color: #475569;
}
.app-chat-panel__composer-immediate-toggle.ant-btn {
color: #475569;
}
.app-chat-panel__composer-immediate-toggle--active.ant-btn {
border-color: #2563eb;
background: linear-gradient(135deg, #2563eb, #2563eb);
color: #eff6ff;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.28);
}
.app-chat-panel__composer-immediate-toggle--active.ant-btn:hover,
.app-chat-panel__composer-immediate-toggle--active.ant-btn:focus-visible {
border-color: #1d4ed8;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #eff6ff;
}
.app-chat-panel__composer-contextless-toggle--active.ant-btn {
border-color: #0f766e;
background: linear-gradient(135deg, #0f766e, #0f766e);
@@ -951,8 +1027,12 @@
border: 1px dashed rgba(148, 163, 184, 0.35);
}
.app-chat-panel__preview-modal .fullscreen-preview-modal__meta {
display: flex;
align-items: center;
}
.app-chat-panel__preview-modal .ant-modal-body {
padding: 12px 0 0;
display: flex;
flex: 1 1 auto;
min-height: 0;
@@ -963,67 +1043,6 @@
z-index: 1600;
}
.app-chat-panel__preview-modal .ant-modal-close {
position: fixed;
top: 18px;
right: 18px;
inset-inline-end: 18px;
width: auto;
height: auto;
padding: 0;
border-radius: 999px;
background: rgba(15, 23, 42, 0.18);
box-shadow: none;
backdrop-filter: blur(3px);
opacity: 0.46;
transition:
opacity 160ms ease,
background-color 160ms ease;
}
.app-chat-panel__preview-modal .ant-modal-close:hover {
background: rgba(15, 23, 42, 0.28);
opacity: 0.7;
}
.app-chat-panel__preview-modal-close-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 56px;
padding: 10px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.app-chat-panel__preview-modal .ant-modal-content {
display: flex;
flex-direction: column;
min-height: 0;
height: 100dvh;
max-height: 100dvh;
padding: 0;
border-radius: 0;
}
.app-chat-panel__preview-modal .ant-modal-header {
margin-bottom: 0;
padding: 16px 20px 12px;
border-radius: 0;
}
.app-chat-panel__preview-modal .ant-modal-title {
padding-right: 40px;
}
.app-chat-panel__preview-modal .ant-modal-footer {
margin-top: 0;
padding: 0 20px 16px;
border-top: 0;
}
.app-chat-panel__preview-stage--modal {
display: flex;
flex: 1 1 auto;
@@ -1049,33 +1068,13 @@
overflow: hidden;
}
.app-chat-panel__preview-modal-meta {
display: flex;
justify-content: flex-start;
padding: 0 20px 12px;
}
.app-chat-panel__preview-modal-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-width: 0;
}
.app-chat-panel__preview-modal-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-chat-panel__preview-modal-findbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 20px 12px;
padding: 12px 14px;
background: rgba(11, 18, 32, 0.96);
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
.app-chat-panel__preview-modal-findbar .ant-input-affix-wrapper {
@@ -1100,6 +1099,8 @@
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich--markdown {
height: 100%;
max-height: none;
padding: 20px 18px 28px;
background: #f8fafc;
}
.app-chat-panel__preview-modal .previewer-ui__editor,
@@ -1115,23 +1116,54 @@
.app-chat-panel__preview-modal .app-chat-panel__preview-image {
height: 100%;
max-height: none;
background: #fff;
background: #0b1220;
object-position: center;
}
.app-chat-panel__preview-modal .app-chat-panel__preview-rich--markdown .markdown-preview,
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich--markdown .markdown-preview {
color: #0f172a;
}
.app-chat-panel__preview-modal .app-chat-panel__preview-rich--markdown code,
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich--markdown code {
color: #0f172a;
}
.app-chat-panel__preview-modal .previewer-ui__editor-body {
max-height: none;
padding-inline: 0;
}
.app-chat-panel__preview-modal--html-mobile .ant-modal-content {
.app-chat-panel__preview-zoom-stage {
width: 100%;
height: 100%;
background: #0b1220;
}
.app-chat-panel__preview-zoom-content {
display: flex;
width: 100%;
height: 100%;
}
.app-chat-panel__preview-zoom-content .app-chat-panel__preview-image,
.app-chat-panel__preview-zoom-content .app-chat-panel__preview-frame {
width: 100%;
height: 100%;
min-height: 100%;
object-fit: contain;
border: 0;
border-radius: 0;
}
.app-chat-panel__preview-zoom-content .app-chat-panel__preview-frame {
pointer-events: none;
background: #fff;
}
.app-chat-panel__preview-modal--html-mobile .ant-modal-header,
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-meta,
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-findbar {
display: none;
.app-chat-panel__preview-modal--html-mobile .ant-modal-content {
background: #fff;
}
.app-chat-panel__preview-modal--html-mobile .ant-modal-body,
@@ -1166,12 +1198,6 @@
}
@media (max-width: 720px) {
.app-chat-panel__preview-modal .ant-modal-close {
top: 12px;
right: 12px;
inset-inline-end: 12px;
}
.app-chat-panel__preview-stage--html-mobile > * {
padding: 0;
}
@@ -1186,11 +1212,6 @@
}
@media (max-width: 720px) {
.app-chat-panel__preview-modal-title {
align-items: flex-start;
flex-direction: column;
}
.app-chat-panel__preview-modal-findbar {
flex-wrap: wrap;
}
@@ -1310,9 +1331,14 @@
align-items: stretch;
}
.app-chat-panel__composer-actions {
gap: 10px;
}
.app-chat-panel__composer-topline {
flex-direction: row;
align-items: center;
gap: 8px;
}
.app-chat-panel__conversation-badges {
@@ -1343,6 +1369,20 @@
box-sizing: border-box;
}
.app-chat-panel__composer-action-buttons {
gap: 8px;
}
.app-chat-panel__composer-action-buttons .ant-btn,
.app-chat-panel__composer-type .ant-select-selector {
min-height: 34px;
}
.app-chat-panel__composer-action-buttons .ant-btn-icon-only {
width: 34px;
min-width: 34px;
}
.app-chat-panel__composer textarea.ant-input {
height: clamp(104px, 16dvh, 136px);
min-height: clamp(104px, 16dvh, 136px);

61
src/app/main/mainChatPanel/types.ts Executable file → Normal file
View File

@@ -94,9 +94,19 @@ export type ChatViewContext = {
pageUrl: string;
isStandaloneMode: boolean;
pageVisibilityState: 'visible' | 'hidden';
pageFocusState?: 'focused' | 'blurred';
chatTypeId: string | null;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeBaseDescription?: string;
defaultContextIds?: string[];
defaultContexts?: Array<{
id: string;
title: string;
content: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
};
export type ChatConversationSummary = {
@@ -104,6 +114,7 @@ export type ChatConversationSummary = {
clientId: string | null;
isDraftOnly?: boolean;
title: string;
requestBadgeLabel?: string | null;
chatTypeId: string | null;
lastChatTypeId: string | null;
generalSectionName: string | null;
@@ -138,6 +149,9 @@ export type ChatConversationRequestStatus =
export type ChatConversationRequest = {
sessionId: string;
requestId: string;
requesterClientId?: string | null;
requestOrigin?: 'composer' | 'prompt' | null;
parentRequestId?: string | null;
status: ChatConversationRequestStatus;
statusMessage: string | null;
userMessageId: number | null;
@@ -159,10 +173,36 @@ export type ChatConversationActivityLog = {
updatedAt: string | null;
};
export type ChatSourceChangeSnapshot = {
id: string;
sessionId: string;
clientId: string | null;
conversationTitle: string;
chatTypeId: string | null;
chatTypeLabel: string;
requestId: string;
requestTitle: string;
questionText: string;
answerText: string;
status: ChatConversationRequestStatus;
sourceChangedAt: string;
updatedAt: string;
featureTags: string[];
changedFiles: string[];
currentSourceFiles: string[];
diffBlocks: string[];
hasSourceChanges: boolean;
reviewStatus: 'reviewed' | 'not-reviewed';
sourceChangeKind: 'request' | 'verification-group';
sourceEntryIds: string[];
conversationDeletedAt: string | null;
};
export type ChatActivityEvent = {
requestId: string;
line: string;
lineCount: number;
lineNo?: number;
};
export type ChatPanelView = 'chat' | 'runtime' | 'errors';
@@ -303,6 +343,22 @@ export type ChatServerEvent =
sessionId: string;
type: 'chat:activity';
payload: ChatActivityEvent;
}
| {
eventId: number;
sessionId: string;
type: 'notification:messages-updated';
payload:
| {
action: 'created' | 'updated';
itemId: number;
category: string;
read: boolean;
}
| {
action: 'deleted';
itemId: number;
};
};
export type ChatConversationDetailResponse = {
@@ -315,6 +371,11 @@ export type ChatConversationDetailResponse = {
hasOlderMessages: boolean;
};
export type ChatSourceChangeSnapshotListResponse = {
ok: boolean;
items: ChatSourceChangeSnapshot[];
};
export type ErrorLogViewerState = {
errorLogs: ErrorLogItem[];
selectedErrorLogId: number | null;

View File

@@ -35,6 +35,7 @@ var sharedChatConnection = {
visibilityHandlerInstalled: false,
pageShowHandlerInstalled: false,
focusHandlerInstalled: false,
blurHandlerInstalled: false,
onlineHandlerInstalled: false,
hasConnectedOnce: false,
suppressDisconnectNotification: false,
@@ -110,6 +111,9 @@ function sendContextUpdate(context) {
return;
}
var liveVisibilityState = typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible';
var liveFocusState = typeof document !== 'undefined' && typeof document.hasFocus === 'function' && !document.hasFocus()
? 'blurred'
: 'focused';
var livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
socket.send(JSON.stringify({
type: 'context:update',
@@ -121,6 +125,7 @@ function sendContextUpdate(context) {
pageUrl: livePageUrl,
isStandaloneMode: context.isStandaloneMode,
pageVisibilityState: liveVisibilityState,
pageFocusState: liveFocusState,
chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription,
@@ -175,6 +180,10 @@ function stopPresenceMonitoring() {
window.removeEventListener('focus', handleWindowFocus);
sharedChatConnection.focusHandlerInstalled = false;
}
if (sharedChatConnection.blurHandlerInstalled) {
window.removeEventListener('blur', handleWindowBlur);
sharedChatConnection.blurHandlerInstalled = false;
}
if (sharedChatConnection.onlineHandlerInstalled) {
window.removeEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = false;
@@ -199,6 +208,11 @@ function handlePageShow() {
function handleWindowFocus() {
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handleWindowBlur() {
sendContextUpdate(sharedChatConnection.currentContext);
sendPresencePing();
}
function handleWindowOnline() {
ensureSharedSocket();
@@ -227,6 +241,10 @@ function startPresenceMonitoring() {
window.addEventListener('focus', handleWindowFocus);
sharedChatConnection.focusHandlerInstalled = true;
}
if (!sharedChatConnection.blurHandlerInstalled) {
window.addEventListener('blur', handleWindowBlur);
sharedChatConnection.blurHandlerInstalled = true;
}
if (!sharedChatConnection.onlineHandlerInstalled) {
window.addEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = true;

134
src/app/main/mainChatPanel/useChatConnection.ts Executable file → Normal file
View File

@@ -60,8 +60,12 @@ type SharedChatConnection = SharedChatConnectionState & {
consumerCount: number;
pingIntervalId: number | null;
visibilityHandlerInstalled: boolean;
pageHideHandlerInstalled: boolean;
beforeUnloadHandlerInstalled: boolean;
unloadHandlerInstalled: boolean;
pageShowHandlerInstalled: boolean;
focusHandlerInstalled: boolean;
blurHandlerInstalled: boolean;
onlineHandlerInstalled: boolean;
hasConnectedOnce: boolean;
suppressDisconnectNotification: boolean;
@@ -91,8 +95,12 @@ const sharedChatConnection: SharedChatConnection = {
consumerCount: 0,
pingIntervalId: null,
visibilityHandlerInstalled: false,
pageHideHandlerInstalled: false,
beforeUnloadHandlerInstalled: false,
unloadHandlerInstalled: false,
pageShowHandlerInstalled: false,
focusHandlerInstalled: false,
blurHandlerInstalled: false,
onlineHandlerInstalled: false,
hasConnectedOnce: false,
suppressDisconnectNotification: false,
@@ -186,6 +194,10 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio
const liveVisibilityState =
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible';
const liveFocusState =
typeof document !== 'undefined' && typeof document.hasFocus === 'function' && !document.hasFocus()
? 'blurred'
: 'focused';
const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
socket.send(
@@ -199,9 +211,15 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio
pageUrl: livePageUrl,
isStandaloneMode: context.isStandaloneMode,
pageVisibilityState: liveVisibilityState,
pageFocusState: liveFocusState,
chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription,
chatTypeBaseDescription: context.chatTypeBaseDescription,
defaultContextIds: context.defaultContextIds,
defaultContexts: context.defaultContexts,
customContextTitle: context.customContextTitle,
customContextContent: context.customContextContent,
},
}),
);
@@ -224,6 +242,57 @@ function sendPresencePing() {
);
}
function sendImmediateHiddenContextUpdate() {
const context = sharedChatConnection.currentContext;
const socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !context) {
return;
}
const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
socket.send(
JSON.stringify({
type: 'context:update',
payload: {
pageId: context.pageId,
pageTitle: context.pageTitle,
topMenu: context.topMenu,
focusedComponentId: context.focusedComponentId,
pageUrl: livePageUrl,
isStandaloneMode: context.isStandaloneMode,
pageVisibilityState: 'hidden',
pageFocusState: 'blurred',
chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription,
chatTypeBaseDescription: context.chatTypeBaseDescription,
defaultContextIds: context.defaultContextIds,
defaultContexts: context.defaultContexts,
customContextTitle: context.customContextTitle,
customContextContent: context.customContextContent,
},
}),
);
}
function closeSharedSocketForPageExit() {
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
const socket = sharedChatConnection.socketRef.current;
if (!socket) {
return;
}
sharedChatConnection.suppressDisconnectNotification = true;
sharedChatConnection.socketRef.current = null;
socket.close(1000, 'page-exit');
}
function ensureSharedSocket() {
const socket = sharedChatConnection.socketRef.current;
@@ -258,10 +327,25 @@ function stopPresenceMonitoring() {
}
if (sharedChatConnection.visibilityHandlerInstalled) {
window.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('visibilitychange', handleVisibilityChange);
sharedChatConnection.visibilityHandlerInstalled = false;
}
if (sharedChatConnection.pageHideHandlerInstalled) {
window.removeEventListener('pagehide', handlePageHide);
sharedChatConnection.pageHideHandlerInstalled = false;
}
if (sharedChatConnection.beforeUnloadHandlerInstalled) {
window.removeEventListener('beforeunload', handleBeforeUnload);
sharedChatConnection.beforeUnloadHandlerInstalled = false;
}
if (sharedChatConnection.unloadHandlerInstalled) {
window.removeEventListener('unload', handleWindowUnload);
sharedChatConnection.unloadHandlerInstalled = false;
}
if (sharedChatConnection.pageShowHandlerInstalled) {
window.removeEventListener('pageshow', handlePageShow);
sharedChatConnection.pageShowHandlerInstalled = false;
@@ -272,6 +356,11 @@ function stopPresenceMonitoring() {
sharedChatConnection.focusHandlerInstalled = false;
}
if (sharedChatConnection.blurHandlerInstalled) {
window.removeEventListener('blur', handleWindowBlur);
sharedChatConnection.blurHandlerInstalled = false;
}
if (sharedChatConnection.onlineHandlerInstalled) {
window.removeEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = false;
@@ -297,9 +386,30 @@ function handlePageShow() {
sendContextUpdate(sharedChatConnection.currentContext);
}
function handlePageHide() {
sharedChatConnection.lastBackgroundAt = Date.now();
sendImmediateHiddenContextUpdate();
sendPresencePing();
closeSharedSocketForPageExit();
}
function handleBeforeUnload() {
handlePageHide();
}
function handleWindowUnload() {
handlePageHide();
}
function handleWindowFocus() {
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handleWindowBlur() {
sendContextUpdate(sharedChatConnection.currentContext);
sendPresencePing();
}
function handleWindowOnline() {
@@ -322,7 +432,7 @@ function startPresenceMonitoring() {
}
if (!sharedChatConnection.visibilityHandlerInstalled) {
window.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('visibilitychange', handleVisibilityChange);
sharedChatConnection.visibilityHandlerInstalled = true;
}
@@ -331,11 +441,31 @@ function startPresenceMonitoring() {
sharedChatConnection.pageShowHandlerInstalled = true;
}
if (!sharedChatConnection.pageHideHandlerInstalled) {
window.addEventListener('pagehide', handlePageHide);
sharedChatConnection.pageHideHandlerInstalled = true;
}
if (!sharedChatConnection.beforeUnloadHandlerInstalled) {
window.addEventListener('beforeunload', handleBeforeUnload);
sharedChatConnection.beforeUnloadHandlerInstalled = true;
}
if (!sharedChatConnection.unloadHandlerInstalled) {
window.addEventListener('unload', handleWindowUnload);
sharedChatConnection.unloadHandlerInstalled = true;
}
if (!sharedChatConnection.focusHandlerInstalled) {
window.addEventListener('focus', handleWindowFocus);
sharedChatConnection.focusHandlerInstalled = true;
}
if (!sharedChatConnection.blurHandlerInstalled) {
window.addEventListener('blur', handleWindowBlur);
sharedChatConnection.blurHandlerInstalled = true;
}
if (!sharedChatConnection.onlineHandlerInstalled) {
window.addEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = true;

0
src/app/main/mainChatPanel/useErrorLogs.ts Executable file → Normal file
View File

22
src/app/main/mainContent/windowLayout.ts Executable file → Normal file
View File

@@ -37,7 +37,18 @@ export function getPlanStatusFromWindowSelection(selectionId: string): PlanFilte
}
export function getDefaultWindowFrame(selectionId: string, index: number): WindowFrame {
const isSampleSelection = selectionId.startsWith('component:') || selectionId.startsWith('widget:');
const isComponentSelection = selectionId.startsWith('component:');
const isWidgetSelection = selectionId.startsWith('widget:');
const isSampleSelection = isComponentSelection || isWidgetSelection;
if (selectionId === 'page:preview:app') {
return {
x: 16,
y: 16,
width: 1240,
height: 820,
};
}
// Page windows need enough room for nested layouts, while sample windows start compact.
if (selectionId.startsWith('page:')) {
@@ -49,6 +60,15 @@ export function getDefaultWindowFrame(selectionId: string, index: number): Windo
};
}
if (isWidgetSelection) {
return {
x: 0,
y: 0,
width: 100000,
height: 100000,
};
}
if (isSampleSelection) {
return {
x: 48 + (index % 5) * 24,

0
src/app/main/mainView/constants.tsx Executable file → Normal file
View File

0
src/app/main/mainView/index.ts Executable file → Normal file
View File

0
src/app/main/mainView/navigation.ts Executable file → Normal file
View File

65
src/app/main/mainView/searchOptions.ts Executable file → Normal file
View File

@@ -141,6 +141,17 @@ export function buildMainViewSearchOptions({
} satisfies SearchKeywordOption,
]
: []),
{
id: 'page:preview:app',
label: 'Preview App / 모바일 앱 열기',
group: 'Page',
keywords: ['preview', 'iframe', '미리보기', 'preview app', '프리뷰 앱', '모바일 앱', 'cbt app'],
description: 'preview.sm-home.cloud에서 실제 앱 컨테이너 화면을 모바일 해상도로 엽니다.',
onSelect: () => {
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:live',
label: 'Codex Live / Codex Live',
@@ -155,7 +166,7 @@ export function buildMainViewSearchOptions({
},
{
id: 'page:chat:resources',
label: 'Codex Live / 리소스 관리',
label: '리소스 관리 / 리소스 관리',
group: 'Page',
keywords: ['codex live', 'resource', 'resources', 'file', 'files', '리소스', '파일', '파일 시스템'],
onSelect: () => {
@@ -177,34 +188,30 @@ export function buildMainViewSearchOptions({
},
onSelectWindow,
},
...(hasAccess
? [
{
id: 'page:chat:manage',
label: '채팅 관리 / 유형 권한 관리',
group: 'Page',
keywords: ['chat manage', 'chat type', 'permission', '권한', '채팅 유형', '채팅 관리'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('manage');
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:manage-defaults',
label: '채팅 관리 / 기본 유형 관리',
group: 'Page',
keywords: ['chat manage', 'default type', 'default context', '기본 유형', '기본 context', '채팅 관리'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('manage-defaults');
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
]
: []),
{
id: 'page:chat:manage',
label: '채팅 관리 / 유형 권한 관리',
group: 'Page',
keywords: ['chat manage', 'chat type', 'permission', '권한', '채팅 유형', '채팅 관리'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('manage');
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:manage-defaults',
label: '채팅 관리 / 공통 문맥 관리',
group: 'Page',
keywords: ['chat manage', 'default type', 'default context', '기본 유형', '공통 문맥', '기본 context', '채팅 관리'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('manage-defaults');
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
...docFolders.map((folder) => ({
id: `docs-folder:${folder}`,
label: `Docs / ${folder}`,

0
src/app/main/mainView/useMainViewData.ts Executable file → Normal file
View File

0
src/app/main/mainView/utils.ts Executable file → Normal file
View File

View File

@@ -0,0 +1,92 @@
type EdgeSwipeTracking = {
startX: number;
startY: number;
direction: 'back' | 'forward';
};
const EDGE_HOTZONE_PX = 24;
const MIN_HORIZONTAL_SWIPE_PX = 12;
const MAX_VERTICAL_DRIFT_PX = 48;
function isMobileTouchEnvironment() {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches;
}
export function setupMobileNavigationGestureBlocker() {
if (typeof window === 'undefined' || typeof document === 'undefined' || !isMobileTouchEnvironment()) {
return () => undefined;
}
let tracking: EdgeSwipeTracking | null = null;
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch || event.touches.length !== 1) {
tracking = null;
return;
}
if (touch.clientX <= EDGE_HOTZONE_PX) {
tracking = {
startX: touch.clientX,
startY: touch.clientY,
direction: 'back',
};
return;
}
if (touch.clientX >= window.innerWidth - EDGE_HOTZONE_PX) {
tracking = {
startX: touch.clientX,
startY: touch.clientY,
direction: 'forward',
};
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;
const absDeltaY = Math.abs(deltaY);
const isHorizontalEdgeSwipe =
absDeltaY <= MAX_VERTICAL_DRIFT_PX &&
((tracking.direction === 'back' && deltaX >= MIN_HORIZONTAL_SWIPE_PX) ||
(tracking.direction === 'forward' && deltaX <= MIN_HORIZONTAL_SWIPE_PX * -1));
if (!isHorizontalEdgeSwipe) {
return;
}
event.preventDefault();
};
const resetTracking = () => {
tracking = null;
};
document.addEventListener('touchstart', handleTouchStart, { passive: true, capture: true });
document.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
document.addEventListener('touchend', resetTracking, { passive: true, capture: true });
document.addEventListener('touchcancel', resetTracking, { passive: true, capture: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart, true);
document.removeEventListener('touchmove', handleTouchMove, true);
document.removeEventListener('touchend', resetTracking, true);
document.removeEventListener('touchcancel', resetTracking, true);
};
}

View File

@@ -16,7 +16,26 @@ export function renderModalWithEnterConfirm(node: React.ReactNode) {
event.currentTarget.contains(target) &&
isInteractiveTarget(target);
if (event.key !== 'Enter' || event.nativeEvent.isComposing || shouldIgnoreInteractiveTarget) {
if (event.nativeEvent.isComposing) {
return;
}
if (event.key === 'Escape') {
const cancelButton =
event.currentTarget.querySelector<HTMLButtonElement>('.ant-modal-footer .ant-btn:not(.ant-btn-primary)') ??
event.currentTarget.querySelector<HTMLButtonElement>('.ant-modal-close');
if (!cancelButton || cancelButton.disabled) {
return;
}
event.preventDefault();
event.stopPropagation();
cancelButton.click();
return;
}
if (event.key !== 'Enter' || shouldIgnoreInteractiveTarget) {
return;
}

96
src/app/main/notificationApi.ts Executable file → Normal file
View File

@@ -100,13 +100,6 @@ export type ClientNotificationSendResult = {
};
};
export type PwaNotificationTokenPayload = {
token: string;
deviceId?: string;
appOrigin?: string;
appDomain?: string;
};
function getCurrentAppOrigin() {
if (typeof window === 'undefined') {
return '';
@@ -169,7 +162,7 @@ const NOTIFICATION_MESSAGE_TABLE = 'notification_messages';
let notificationMessageTableSetupPromise: Promise<void> | null = null;
function emitNotificationMessagesUpdated() {
export function notifyNotificationMessagesUpdated() {
if (typeof window === 'undefined') {
return;
}
@@ -370,7 +363,7 @@ async function createNotificationMessageViaCrud(payload: CreateNotificationMessa
}
const item = mapNotificationMessageRow(row);
emitNotificationMessagesUpdated();
notifyNotificationMessagesUpdated();
return item;
}
@@ -398,7 +391,7 @@ async function updateNotificationMessageReadStateViaCrud(id: number, read: boole
}
const item = mapNotificationMessageRow(row);
emitNotificationMessagesUpdated();
notifyNotificationMessagesUpdated();
return item;
}
@@ -426,7 +419,7 @@ async function deleteNotificationMessageViaCrud(id: number) {
throw new NotificationApiError('삭제할 알림 메시지를 찾을 수 없습니다.', 404);
}
emitNotificationMessagesUpdated();
notifyNotificationMessagesUpdated();
}
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
@@ -606,7 +599,7 @@ export async function createNotificationMessage(payload: CreateNotificationMessa
}),
});
emitNotificationMessagesUpdated();
notifyNotificationMessagesUpdated();
return response.item;
} catch (error) {
if (error instanceof NotificationApiError && error.status === 404) {
@@ -626,7 +619,7 @@ export async function updateNotificationMessageReadState(id: number, read: boole
}),
});
emitNotificationMessagesUpdated();
notifyNotificationMessagesUpdated();
return response.item;
} catch (error) {
if (error instanceof NotificationApiError && error.status === 404) {
@@ -642,7 +635,7 @@ export async function deleteNotificationMessage(id: number) {
await request<{ ok: boolean; deleted: boolean }>(`/notifications/messages/${id}`, {
method: 'DELETE',
});
emitNotificationMessagesUpdated();
notifyNotificationMessagesUpdated();
} catch (error) {
if (error instanceof NotificationApiError && error.status === 404) {
return deleteNotificationMessageViaCrud(id);
@@ -690,10 +683,34 @@ export async function markChatNotificationMessagesAsRead(sessionId: string) {
return targetIds.length;
}
export async function dismissChatWebPushNotifications(sessionId: string) {
export async function dismissChatNotificationMessages(sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId || typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
if (!normalizedSessionId) {
return 0;
}
const response = await fetchNotificationMessages({
status: 'all',
limit: 100,
});
const targetIds = response.items
.filter((item) => item.category === 'chat' && getNotificationMetadataText(item.metadata, 'sessionId') === normalizedSessionId)
.map((item) => item.id);
if (targetIds.length === 0) {
return 0;
}
await Promise.allSettled(targetIds.map((id) => deleteNotificationMessage(id)));
return targetIds.length;
}
export async function dismissChatWebPushNotifications(sessionId?: string) {
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return 0;
}
@@ -716,17 +733,22 @@ export async function dismissChatWebPushNotifications(sessionId: string) {
const notificationType = getNotificationMetadataText(notificationData, 'type');
const notificationThreadId = getNotificationMetadataText(notificationData, 'threadId');
if (
(
notificationCategory === 'chat' ||
notificationType.startsWith('chat') ||
notificationThreadId.startsWith('chat:')
) &&
getNotificationMetadataText(notificationData, 'sessionId') === normalizedSessionId
) {
notification.close();
dismissedCount += 1;
const isChatNotification =
notificationCategory === 'chat' ||
notificationType.startsWith('chat') ||
notificationThreadId.startsWith('chat:');
const notificationSessionId = getNotificationMetadataText(notificationData, 'sessionId');
if (!isChatNotification) {
return;
}
if (normalizedSessionId && notificationSessionId !== normalizedSessionId) {
return;
}
notification.close();
dismissedCount += 1;
});
return dismissedCount;
@@ -761,28 +783,6 @@ export async function unregisterWebPushSubscription(endpoint: string) {
});
}
export async function registerPwaNotificationToken(payload: PwaNotificationTokenPayload) {
return request<{ ok: boolean; token: string }>('/notifications/tokens/ios', {
method: 'PUT',
body: JSON.stringify({
token: payload.token,
deviceId: payload.deviceId,
appOrigin: payload.appOrigin || getCurrentAppOrigin(),
appDomain: payload.appDomain || getCurrentAppDomain(),
enabled: true,
}),
});
}
export async function unregisterPwaNotificationToken(token: string) {
return request<{ ok: boolean; token: string; removed: boolean }>('/notifications/tokens/ios', {
method: 'DELETE',
body: JSON.stringify({
token,
}),
});
}
export function shouldFallbackToLocalNotification(result: ClientNotificationSendResult) {
return (
result.web.skipped === true ||

61
src/app/main/notificationIdentity.ts Executable file → Normal file
View File

@@ -1,89 +1,54 @@
import { clearClientId, getOrCreateClientId } from './clientIdentity';
import { isPreviewRuntime } from './previewRuntime';
export const NOTIFICATION_DEVICE_ID_STORAGE_KEY = 'work-server.notification.device-id';
export const PWA_NOTIFICATION_TOKEN_STORAGE_KEY = 'work-server.notification.pwa-token';
const PREVIEW_NOTIFICATION_DEVICE_ID_STORAGE_KEY = 'work-server.preview-runtime.notification.device-id';
export type AutomationNotificationPreferenceTarget = {
targetKind: 'client' | 'ios-token' | 'ios-token-client';
targetKind: 'client';
targetId: string;
};
export function buildScopedPwaNotificationTargetId(token: string, clientId: string) {
return [token.trim(), clientId.trim()].filter(Boolean).join('::client::');
}
export function getSavedNotificationDeviceId() {
if (typeof window === 'undefined') {
return '';
}
const isPreview = isPreviewRuntime();
const storage = isPreview ? window.sessionStorage : window.localStorage;
const deviceIdStorageKey = isPreview ? PREVIEW_NOTIFICATION_DEVICE_ID_STORAGE_KEY : NOTIFICATION_DEVICE_ID_STORAGE_KEY;
const clientId = getOrCreateClientId();
if (clientId) {
window.localStorage.setItem(NOTIFICATION_DEVICE_ID_STORAGE_KEY, clientId);
storage.setItem(deviceIdStorageKey, clientId);
return clientId;
}
const saved = window.localStorage.getItem(NOTIFICATION_DEVICE_ID_STORAGE_KEY);
const saved = storage.getItem(deviceIdStorageKey);
if (saved) {
return saved;
}
const generated = `web-${Date.now()}`;
window.localStorage.setItem(NOTIFICATION_DEVICE_ID_STORAGE_KEY, generated);
storage.setItem(deviceIdStorageKey, generated);
return generated;
}
export function getSavedPwaNotificationToken() {
if (typeof window === 'undefined') {
return '';
}
return window.localStorage.getItem(PWA_NOTIFICATION_TOKEN_STORAGE_KEY)?.trim() ?? '';
}
export function setSavedPwaNotificationToken(token: string | null | undefined) {
if (typeof window === 'undefined') {
return;
}
const normalizedToken = token?.trim() ?? '';
if (normalizedToken) {
window.localStorage.setItem(PWA_NOTIFICATION_TOKEN_STORAGE_KEY, normalizedToken);
} else {
window.localStorage.removeItem(PWA_NOTIFICATION_TOKEN_STORAGE_KEY);
}
}
export function clearNotificationIdentity() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(NOTIFICATION_DEVICE_ID_STORAGE_KEY);
window.localStorage.removeItem(PWA_NOTIFICATION_TOKEN_STORAGE_KEY);
const storage = isPreviewRuntime() ? window.sessionStorage : window.localStorage;
const deviceIdStorageKey = isPreviewRuntime() ? PREVIEW_NOTIFICATION_DEVICE_ID_STORAGE_KEY : NOTIFICATION_DEVICE_ID_STORAGE_KEY;
storage.removeItem(deviceIdStorageKey);
clearClientId();
}
export function getAutomationNotificationPreferenceTarget(): AutomationNotificationPreferenceTarget | null {
const pwaToken = getSavedPwaNotificationToken();
const clientId = getSavedNotificationDeviceId();
if (pwaToken && clientId) {
return {
targetKind: 'ios-token-client',
targetId: buildScopedPwaNotificationTargetId(pwaToken, clientId),
};
}
if (pwaToken) {
return {
targetKind: 'ios-token',
targetId: pwaToken,
};
}
if (!clientId) {
return null;
}

30
src/app/main/pages/ApisPage.tsx Executable file → Normal file
View File

@@ -1,31 +1,45 @@
import { Card, Typography } from 'antd';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { ComponentSamplesLayout } from '../../../features/layout/component-sample-gallery';
import { SampleWidgetsLayout } from '../../../features/layout/widget-sample-gallery';
import { useMainLayoutContext } from '../layout/MainLayoutContext';
import { readPreviewTargetDescriptorFromUrl } from '../previewRuntime';
const { Paragraph } = Typography;
const HIDDEN_COMPONENT_IDS = ['search-command-modal', 'window-ui'];
export function ApisPage() {
const location = useLocation();
const { selectedApiMenu, componentSampleEntries, widgetSampleEntries } = useMainLayoutContext();
const previewTarget = useMemo(() => readPreviewTargetDescriptorFromUrl(), [location.search]);
const isSingleWidgetPreview = selectedApiMenu === 'widgets' && previewTarget?.type === 'widget';
return (
<div className="app-main-panel">
<div className={`app-main-panel${isSingleWidgetPreview ? ' app-main-panel--widget-preview' : ''}`}>
<Card
title={selectedApiMenu === 'components' ? 'APIs / Components' : 'APIs / Widgets'}
className="app-main-card"
className={`app-main-card${isSingleWidgetPreview ? ' app-main-card--widget-preview' : ''}`}
bordered={false}
>
<Paragraph className="app-main-copy">
{selectedApiMenu === 'components'
? '공통 UI 컴포넌트 샘플과 확장 샘플을 확인합니다.'
: '공통 위젯 샘플을 확인합니다.'}
</Paragraph>
{isSingleWidgetPreview ? null : (
<Paragraph className="app-main-copy">
{selectedApiMenu === 'components'
? '공통 UI 컴포넌트 샘플과 확장 샘플을 확인합니다.'
: '공통 위젯 샘플을 확인합니다.'}
</Paragraph>
)}
{selectedApiMenu === 'components' ? (
<ComponentSamplesLayout entries={componentSampleEntries} excludeComponentIds={HIDDEN_COMPONENT_IDS} />
) : (
<SampleWidgetsLayout entries={widgetSampleEntries} />
<SampleWidgetsLayout
entries={widgetSampleEntries}
includeComponentIds={isSingleWidgetPreview ? [previewTarget.componentId] : []}
includeSampleIds={isSingleWidgetPreview && previewTarget.sampleId ? [previewTarget.sampleId] : []}
disableWidgetCardWrapper={isSingleWidgetPreview}
singlePreviewMode={isSingleWidgetPreview}
/>
)}
</Card>
</div>

0
src/app/main/pages/ChatPage.tsx Executable file → Normal file
View File

0
src/app/main/pages/DocsPage.tsx Executable file → Normal file
View File

0
src/app/main/pages/PlansPage.tsx Executable file → Normal file
View File

46
src/app/main/pages/PlayPage.tsx Executable file → Normal file
View File

@@ -1,37 +1,47 @@
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
import { useLocation } from 'react-router-dom';
import { SampleWidgetsLayout } from '../../../features/layout/widget-sample-gallery';
import { CbtPlayAppView } from '../../../views/play/apps/cbt/CbtPlayAppView';
import { TestPlayAppView } from '../../../views/play/apps/test/TestPlayAppView';
import { FeatureMenuLayoutPage } from '../../../features/layout/feature-menu';
import { MemoLayoutPage } from '../../../features/layout/memo';
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
import { useMainLayoutContext } from '../layout/MainLayoutContext';
import { readPreviewTargetDescriptorFromUrl } from '../previewRuntime';
import { resolveSavedLayoutIdFromMenuKey } from '../routes';
import { renderSavedLayoutContent } from '../../../features/layout/renderSavedLayoutContent';
export function PlayPage() {
const { selectedPlayMenu, savedLayouts, setSavedLayouts } = useMainLayoutContext();
const location = useLocation();
const { selectedPlayMenu, savedLayouts, setSavedLayouts, widgetSampleEntries } = useMainLayoutContext();
const selectedSavedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
const selectedSavedLayout = selectedSavedLayoutId
? savedLayouts.find((layout) => layout.id === selectedSavedLayoutId) ?? null
: null;
const isMemoLayout = selectedSavedLayout?.name === '메모';
const isFeatureMenuLayout = selectedSavedLayout?.name === '기능설명 관리';
const previewTarget = readPreviewTargetDescriptorFromUrl();
const isWidgetPreview = selectedPlayMenu === 'cbt' && previewTarget?.type === 'widget';
const panelClassName = selectedSavedLayoutId ? 'app-main-panel app-main-panel--play app-main-panel--play-saved' : 'app-main-panel app-main-panel--play';
return (
<div className={panelClassName}>
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
{selectedPlayMenu === 'test' ? <TestPlayAppView /> : null}
{selectedPlayMenu === 'cbt' ? <CbtPlayAppView /> : null}
{selectedSavedLayoutId && isMemoLayout ? <MemoLayoutPage layoutId={selectedSavedLayoutId} /> : null}
{selectedSavedLayoutId && isFeatureMenuLayout ? (
<FeatureMenuLayoutPage
layoutId={selectedSavedLayoutId}
savedLayouts={savedLayouts}
onSavedLayoutsChange={setSavedLayouts}
{isWidgetPreview ? (
<SampleWidgetsLayout
key={location.search}
entries={widgetSampleEntries}
includeComponentIds={[previewTarget.componentId]}
includeSampleIds={previewTarget.sampleId ? [previewTarget.sampleId] : []}
disableWidgetCardWrapper
singlePreviewMode
/>
) : null}
{selectedSavedLayoutId && !isMemoLayout && !isFeatureMenuLayout ? (
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
) : null}
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
{selectedPlayMenu === 'test' ? <TestPlayAppView /> : null}
{selectedPlayMenu === 'cbt' && !isWidgetPreview ? <CbtPlayAppView /> : null}
{selectedSavedLayoutId && selectedSavedLayout
? renderSavedLayoutContent({
layoutId: selectedSavedLayoutId,
layout: selectedSavedLayout,
savedLayouts,
onSavedLayoutsChange: setSavedLayouts,
})
: null}
</div>
);
}

View File

@@ -0,0 +1,294 @@
const PREVIEW_RUNTIME_QUERY_KEY = 'appPreviewMode';
const PREVIEW_RUNTIME_PARENT_ORIGIN_KEY = 'previewParentOrigin';
const PREVIEW_RUNTIME_TOKEN_QUERY_KEY = 'registeredAccessToken';
const PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY = 'previewDeviceMode';
const PREVIEW_TARGET_TYPE_QUERY_KEY = 'previewTargetType';
const PREVIEW_TARGET_COMPONENT_ID_QUERY_KEY = 'previewComponentId';
const PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY = 'previewSampleId';
const PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY = 'work-app.preview-runtime.cache-reset.v1';
const PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY = '__previewRuntimeCacheReset';
const PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS = [
PREVIEW_RUNTIME_QUERY_KEY,
PREVIEW_RUNTIME_PARENT_ORIGIN_KEY,
PREVIEW_RUNTIME_TOKEN_QUERY_KEY,
PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY,
] as const;
export type PreviewTargetDescriptor =
| {
type: 'widget';
componentId: string;
sampleId?: string;
}
| null;
export function isPreviewRuntime() {
if (typeof window === 'undefined') {
return false;
}
return new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_QUERY_KEY) === '1';
}
export function isPreviewAppOrigin() {
if (typeof window === 'undefined') {
return false;
}
return window.location.origin === resolvePreviewAppOrigin();
}
function readPreviewRuntimeCacheResetMarker() {
if (typeof window === 'undefined') {
return '';
}
try {
return window.sessionStorage.getItem(PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY)?.trim() ?? '';
} catch {
return '';
}
}
function writePreviewRuntimeCacheResetMarker(value: string) {
if (typeof window === 'undefined') {
return;
}
try {
if (value) {
window.sessionStorage.setItem(PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY, value);
} else {
window.sessionStorage.removeItem(PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY);
}
} catch {
// Ignore storage access failures in restricted runtimes.
}
}
function buildPreviewRuntimeLocationKey() {
if (typeof window === 'undefined') {
return '';
}
return `${window.location.origin}${window.location.pathname}`;
}
function buildPreviewRuntimeCacheResetUrl() {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY, `${Date.now()}`);
return nextUrl.toString();
}
async function clearPreviewRuntimeServiceWorkersAndCaches() {
if (typeof window === 'undefined') {
return false;
}
let changed = false;
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
if (registrations.length > 0) {
changed = true;
}
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)));
} catch {
// Ignore cleanup failure and continue.
}
}
if ('caches' in window) {
try {
const cacheKeys = await caches.keys();
if (cacheKeys.length > 0) {
changed = true;
}
await Promise.all(cacheKeys.map((cacheKey) => caches.delete(cacheKey).catch(() => false)));
} catch {
// Ignore cache cleanup failure and continue.
}
}
return changed;
}
export async function ensurePreviewRuntimeFreshState() {
if (typeof window === 'undefined' || (!isPreviewRuntime() && !isPreviewAppOrigin())) {
return;
}
const currentLocationKey = buildPreviewRuntimeLocationKey();
const cleanedLocationKey = readPreviewRuntimeCacheResetMarker();
const resetSearchParam = new URL(window.location.href).searchParams.get(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY)?.trim() ?? '';
if (cleanedLocationKey === currentLocationKey && !resetSearchParam) {
return;
}
const changed = await clearPreviewRuntimeServiceWorkersAndCaches();
if (!changed) {
if (resetSearchParam) {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.delete(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY);
window.history.replaceState(window.history.state, '', `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`);
}
writePreviewRuntimeCacheResetMarker(currentLocationKey);
return;
}
writePreviewRuntimeCacheResetMarker(currentLocationKey);
window.location.replace(buildPreviewRuntimeCacheResetUrl());
await new Promise(() => {
// Keep the bootstrap suspended until the browser navigates away.
});
}
export function getPreviewRuntimeParentOrigin() {
if (typeof window === 'undefined') {
return '';
}
return new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_PARENT_ORIGIN_KEY)?.trim() ?? '';
}
export function readPreviewRuntimeDeviceModeFromUrl(): 'desktop' | 'mobile' | null {
if (typeof window === 'undefined') {
return null;
}
const value = new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY)?.trim() ?? '';
if (value === 'desktop' || value === 'mobile') {
return value;
}
return null;
}
export function readPreviewRuntimeTokenFromUrl() {
if (typeof window === 'undefined') {
return '';
}
return new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_TOKEN_QUERY_KEY)?.trim() ?? '';
}
export function clearPreviewRuntimeTokenFromUrl() {
if (typeof window === 'undefined') {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has(PREVIEW_RUNTIME_TOKEN_QUERY_KEY)) {
return;
}
url.searchParams.delete(PREVIEW_RUNTIME_TOKEN_QUERY_KEY);
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
}
export function resolvePreviewAppOrigin() {
return 'https://preview.sm-home.cloud';
}
export function appendPreviewRuntimeSearch(path: string, sourceSearch = '') {
if (!path) {
return path;
}
const [rawPathname, rawHash = ''] = path.split('#', 2);
const [pathname, rawSearch = ''] = rawPathname.split('?', 2);
const nextSearchParams = new URLSearchParams(rawSearch);
const sourceSearchParams = new URLSearchParams(sourceSearch.startsWith('?') ? sourceSearch.slice(1) : sourceSearch);
PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS.forEach((key) => {
const value = sourceSearchParams.get(key)?.trim() ?? '';
if (value) {
nextSearchParams.set(key, value);
return;
}
nextSearchParams.delete(key);
});
const nextSearch = nextSearchParams.toString();
const nextHash = rawHash ? `#${rawHash}` : '';
return `${pathname}${nextSearch ? `?${nextSearch}` : ''}${nextHash}`;
}
export function readPreviewTargetDescriptorFromUrl(): PreviewTargetDescriptor {
if (typeof window === 'undefined') {
return null;
}
const searchParams = new URLSearchParams(window.location.search);
const targetType = searchParams.get(PREVIEW_TARGET_TYPE_QUERY_KEY)?.trim() ?? '';
if (targetType !== 'widget') {
return null;
}
const componentId = searchParams.get(PREVIEW_TARGET_COMPONENT_ID_QUERY_KEY)?.trim() ?? '';
const sampleId = searchParams.get(PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY)?.trim() ?? '';
if (!componentId) {
return null;
}
return {
type: 'widget',
componentId,
sampleId: sampleId || undefined,
};
}
export function buildPreviewRuntimeUrl(
pathname: string,
search = '',
token = '',
targetDescriptor: PreviewTargetDescriptor = null,
deviceMode: 'desktop' | 'mobile' = 'desktop',
) {
const targetUrl = new URL(pathname || '/', resolvePreviewAppOrigin());
if (search) {
const sourceParams = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
sourceParams.forEach((value, key) => {
targetUrl.searchParams.set(key, value);
});
}
targetUrl.searchParams.set(PREVIEW_RUNTIME_QUERY_KEY, '1');
if (typeof window !== 'undefined') {
targetUrl.searchParams.set(PREVIEW_RUNTIME_PARENT_ORIGIN_KEY, window.location.origin);
}
targetUrl.searchParams.set(PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY, deviceMode);
if (token.trim()) {
targetUrl.searchParams.set(PREVIEW_RUNTIME_TOKEN_QUERY_KEY, token.trim());
}
if (targetDescriptor?.type === 'widget') {
targetUrl.searchParams.set(PREVIEW_TARGET_TYPE_QUERY_KEY, 'widget');
targetUrl.searchParams.set(PREVIEW_TARGET_COMPONENT_ID_QUERY_KEY, targetDescriptor.componentId);
if (targetDescriptor.sampleId?.trim()) {
targetUrl.searchParams.set(PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY, targetDescriptor.sampleId.trim());
} else {
targetUrl.searchParams.delete(PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY);
}
}
return targetUrl.toString();
}

59
src/app/main/routes.tsx Executable file → Normal file
View File

@@ -21,6 +21,22 @@ export type PlanSectionKey =
export type ChatSectionKey = 'live' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults';
export type PlaySectionKey = 'layout' | 'test' | 'cbt';
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
export const CHAT_SECTION_GROUP_LABELS: Record<ChatSectionKey, string> = {
live: 'Codex Live',
changes: 'Codex Live',
resources: '리소스 관리',
errors: '앱로그',
manage: '채팅 관리',
'manage-defaults': '채팅 관리',
};
export const CHAT_SECTION_LABELS: Record<ChatSectionKey, string> = {
live: 'Codex Live',
changes: '변경 이력',
resources: '리소스 관리',
errors: '에러 로그',
manage: '유형 권한 관리',
'manage-defaults': '공통 문맥 관리',
};
export const DOCS_DEFAULT_FOLDER = 'project';
export const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const;
@@ -237,7 +253,7 @@ function renderChatUnreadLabel(label: string, unreadCount: number) {
);
}
export function buildChatMenuItems(hasAccess = true, unreadCount = 0): MenuProps['items'] {
export function buildChatMenuItems(_hasAccess = true, unreadCount = 0): MenuProps['items'] {
return [
{
key: 'codex-live-group',
@@ -255,19 +271,15 @@ export function buildChatMenuItems(hasAccess = true, unreadCount = 0): MenuProps
label: '앱로그',
children: [{ key: 'errors', label: '에러 로그' }],
},
...(hasAccess
? [
{
key: 'chat-manage-group',
icon: <MessageOutlined />,
label: '채팅 관리',
children: [
{ key: 'manage', label: '유형 권한 관리' },
{ key: 'manage-defaults', label: '기본 유형 관리' },
],
},
]
: []),
{
key: 'chat-manage-group',
icon: <MessageOutlined />,
label: '채팅 관리',
children: [
{ key: 'manage', label: '유형 권한 관리' },
{ key: 'manage-defaults', label: '공통 문맥 관리' },
],
},
];
}
@@ -379,18 +391,7 @@ export function resolveCurrentPageDescriptor(params: {
}
if (topMenu === 'chat') {
const title =
chatMenu === 'errors'
? '앱로그 / 에러 로그'
: chatMenu === 'changes'
? 'Codex Live / 변경 이력'
: chatMenu === 'resources'
? 'Codex Live / 리소스 관리'
: chatMenu === 'manage'
? '채팅 관리 / 유형 권한 관리'
: chatMenu === 'manage-defaults'
? '채팅 관리 / 기본 유형 관리'
: 'Codex Live / Codex Live';
const title = `${CHAT_SECTION_GROUP_LABELS[chatMenu]} / ${CHAT_SECTION_LABELS[chatMenu]}`;
return {
id: `app-log:${chatMenu}`,
@@ -413,11 +414,15 @@ export function resolveTopMenuPath(menu: HeaderTopMenuKey, currentDocsFolder: st
return buildDocsPath(currentDocsFolder);
}
if (menu === 'plans') {
return buildPlansPath('all');
}
if (menu === 'play') {
return buildPlayPath('layout');
}
return buildChatPath('live');
return buildPlansPath('all');
}
export function createPageWindowId(topMenu: TopMenuKey, section: string) {

View File

@@ -0,0 +1,53 @@
import type { SearchKeywordOption } from '../../components/search';
import type { SearchOpenMode } from '../../layer/search/types';
const RECENT_SEARCH_OPTION_IDS_STORAGE_KEY_PREFIX = 'work-app.search.recent-option-ids';
const MAX_RECENT_SEARCH_OPTIONS = 5;
function resolveStorageKey(mode: SearchOpenMode) {
return `${RECENT_SEARCH_OPTION_IDS_STORAGE_KEY_PREFIX}.${mode}`;
}
function readStorageValue(mode: SearchOpenMode) {
if (typeof window === 'undefined') {
return [];
}
try {
const rawValue = window.localStorage.getItem(resolveStorageKey(mode));
const parsed = rawValue ? JSON.parse(rawValue) : [];
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) : [];
} catch {
return [];
}
}
export function pushRecentSearchOption(optionId: string, mode: SearchOpenMode) {
if (typeof window === 'undefined') {
return;
}
const normalizedOptionId = optionId.trim();
if (!normalizedOptionId) {
return;
}
const nextIds = [normalizedOptionId, ...readStorageValue(mode).filter((value) => value !== normalizedOptionId)].slice(
0,
MAX_RECENT_SEARCH_OPTIONS,
);
try {
window.localStorage.setItem(resolveStorageKey(mode), JSON.stringify(nextIds));
} catch {
// ignore local storage quota or privacy errors
}
}
export function resolveRecentSearchOptions(options: SearchKeywordOption[], mode: SearchOpenMode) {
const optionMap = new Map(options.map((option) => [option.id, option]));
return readStorageValue(mode)
.map((optionId) => optionMap.get(optionId) ?? null)
.filter((option): option is SearchKeywordOption => option !== null);
}

View File

@@ -0,0 +1,78 @@
const DEFAULT_THEME_COLOR = '#eff6ff';
function resolveThemeColor() {
if (typeof window === 'undefined') {
return DEFAULT_THEME_COLOR;
}
const hostname = window.location.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0') {
return '#e2e8f0';
}
if (hostname.startsWith('test.')) {
return '#dcfce7';
}
if (hostname.startsWith('rel.')) {
return '#fed7aa';
}
if (hostname.startsWith('preview.')) {
return '#dbeafe';
}
return '#f3e8ff';
}
function setThemeColor(color: string) {
if (typeof document === 'undefined') {
return;
}
let themeColorMeta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
if (!themeColorMeta) {
themeColorMeta = document.createElement('meta');
themeColorMeta.name = 'theme-color';
document.head.append(themeColorMeta);
}
themeColorMeta.content = color;
}
export function syncAppThemeColor() {
const color = resolveThemeColor();
setThemeColor(color);
}
export function installAppThemeColorSync() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return () => undefined;
}
const sync = () => {
syncAppThemeColor();
window.requestAnimationFrame(() => {
syncAppThemeColor();
});
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
sync();
}
};
sync();
window.addEventListener('focus', sync);
window.addEventListener('pageshow', sync);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', sync);
window.removeEventListener('pageshow', sync);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}

73
src/app/main/tokenAccess.ts Executable file → Normal file
View File

@@ -1,14 +1,71 @@
import { useEffect, useState } from 'react';
import {
clearPreviewRuntimeTokenFromUrl,
isPreviewRuntime,
readPreviewRuntimeTokenFromUrl,
} from './previewRuntime';
export const TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token';
export const TOKEN_ACCESS_SYNC_EVENT = 'work-app:token-access-changed';
export const ALLOWED_REGISTRATION_TOKEN =
import.meta.env.VITE_ALLOWED_REGISTRATION_TOKEN?.trim() || 'usr_7f3a9c2d8e1b4a6f';
const PREVIEW_RUNTIME_TOKEN_STORAGE_KEY = 'work-app.preview-runtime.registered-token';
let previewRuntimeTokenMemory = '';
function normalizeToken(value: string | null | undefined) {
return value?.trim() ?? '';
}
function readStorageToken(storage: Storage | null, key: string) {
try {
return normalizeToken(storage?.getItem(key));
} catch {
return '';
}
}
function writeStorageToken(storage: Storage | null, key: string, token: string) {
try {
if (token) {
storage?.setItem(key, token);
} else {
storage?.removeItem(key);
}
} catch {
// Ignore storage access errors in restricted preview runtimes.
}
}
function bootstrapRegisteredAccessToken() {
if (typeof window === 'undefined') {
return;
}
const tokenFromUrl = readPreviewRuntimeTokenFromUrl();
if (isPreviewRuntime()) {
if (!tokenFromUrl) {
previewRuntimeTokenMemory = readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
return;
}
previewRuntimeTokenMemory = tokenFromUrl;
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, tokenFromUrl);
clearPreviewRuntimeTokenFromUrl();
return;
}
if (!tokenFromUrl) {
return;
}
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, tokenFromUrl);
clearPreviewRuntimeTokenFromUrl();
}
bootstrapRegisteredAccessToken();
export function isAllowedRegistrationToken(token: string | null | undefined) {
return normalizeToken(token) === ALLOWED_REGISTRATION_TOKEN;
}
@@ -18,7 +75,14 @@ export function getRegisteredAccessToken() {
return '';
}
return normalizeToken(window.localStorage.getItem(TOKEN_ACCESS_STORAGE_KEY));
if (isPreviewRuntime()) {
const previewToken =
readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY) || previewRuntimeTokenMemory;
return previewToken;
}
return readStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY);
}
export function hasRegisteredAccessTokenAccess() {
@@ -32,10 +96,11 @@ export function setRegisteredAccessToken(token: string | null | undefined) {
const normalizedToken = normalizeToken(token);
if (normalizedToken) {
window.localStorage.setItem(TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
if (isPreviewRuntime()) {
previewRuntimeTokenMemory = normalizedToken;
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, normalizedToken);
} else {
window.localStorage.removeItem(TOKEN_ACCESS_STORAGE_KEY);
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
}
window.dispatchEvent(new CustomEvent(TOKEN_ACCESS_SYNC_EVENT));

1
src/app/main/types.ts Executable file → Normal file
View File

@@ -60,6 +60,7 @@ export type MainSidebarProps = {
export type MainContentProps = {
contentExpanded: boolean;
sidebarOverlayActive?: boolean;
disableWindowLayer?: boolean;
onToggleContentExpanded: () => void;
children: ReactNode;
};

View File

@@ -2,6 +2,9 @@ const APP_VIEWPORT_HEIGHT_VAR = '--app-viewport-height';
const APP_VIEWPORT_WIDTH_VAR = '--app-viewport-width';
const APP_VISUAL_VIEWPORT_HEIGHT_VAR = '--app-visual-viewport-height';
const APP_VISUAL_VIEWPORT_WIDTH_VAR = '--app-visual-viewport-width';
const VIEWPORT_RECOVERY_INTERVAL_MS = 120;
const VIEWPORT_RECOVERY_MAX_DURATION_MS = 1800;
const VIEWPORT_RECOVERY_STABLE_TICKS = 3;
function roundViewportSize(value: number) {
return Math.round(value * 100) / 100;
@@ -41,6 +44,53 @@ function getVisualViewportSize() {
};
}
function resetDocumentViewportOffset() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
const documentElement = document.documentElement;
const body = document.body;
const viewport = window.visualViewport;
const layoutScrollTop = Math.max(window.scrollY || 0, documentElement?.scrollTop || 0, body?.scrollTop || 0);
const visualOffsetTop = Math.max(viewport?.offsetTop ?? 0, viewport?.pageTop ?? 0);
if (layoutScrollTop <= 0 && visualOffsetTop <= 0) {
return;
}
window.scrollTo(0, 0);
if (documentElement) {
documentElement.scrollTop = 0;
}
if (body) {
body.scrollTop = 0;
}
}
function getViewportRecoveryStateKey() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return 'ssr';
}
const documentElement = document.documentElement;
const body = document.body;
const viewport = window.visualViewport;
const layoutViewport = getLayoutViewportSize();
const visualViewport = getVisualViewportSize();
return JSON.stringify({
layoutHeight: layoutViewport.height,
layoutWidth: layoutViewport.width,
visualHeight: visualViewport.height,
visualWidth: visualViewport.width,
scrollY: roundViewportSize(Math.max(window.scrollY || 0, documentElement?.scrollTop || 0, body?.scrollTop || 0)),
offsetTop: roundViewportSize(Math.max(viewport?.offsetTop ?? 0, viewport?.pageTop ?? 0)),
});
}
export function applyViewportCssVars() {
if (typeof document === 'undefined') {
return;
@@ -48,7 +98,7 @@ export function applyViewportCssVars() {
const layoutViewport = getLayoutViewportSize();
const visualViewport = getVisualViewportSize();
const resolvedHeight = Math.max(layoutViewport.height, visualViewport.height);
const resolvedHeight = visualViewport.height > 0 ? visualViewport.height : layoutViewport.height;
document.documentElement.style.setProperty(APP_VIEWPORT_HEIGHT_VAR, `${resolvedHeight}px`);
document.documentElement.style.setProperty(APP_VIEWPORT_WIDTH_VAR, `${layoutViewport.width}px`);
@@ -62,26 +112,90 @@ export function bindViewportCssVars() {
}
let frameId = 0;
const timeoutIds = new Set<number>();
const viewport = window.visualViewport;
const sync = () => {
const sync = (options?: { resetScroll?: boolean }) => {
window.cancelAnimationFrame(frameId);
frameId = window.requestAnimationFrame(() => {
if (options?.resetScroll) {
resetDocumentViewportOffset();
}
applyViewportCssVars();
});
};
sync();
window.addEventListener('resize', sync);
window.addEventListener('orientationchange', sync);
viewport?.addEventListener('resize', sync);
viewport?.addEventListener('scroll', sync);
const clearScheduledSyncs = () => {
timeoutIds.forEach((timeoutId) => {
window.clearTimeout(timeoutId);
});
timeoutIds.clear();
};
const scheduleRecoverySync = () => {
clearScheduledSyncs();
sync({
resetScroll: true,
});
const startedAt = Date.now();
let previousState = getViewportRecoveryStateKey();
let stableTicks = 0;
const continueRecoverySync = () => {
const timeoutId = window.setTimeout(() => {
timeoutIds.delete(timeoutId);
sync({
resetScroll: true,
});
const nextState = getViewportRecoveryStateKey();
stableTicks = nextState === previousState ? stableTicks + 1 : 0;
previousState = nextState;
if (
stableTicks >= VIEWPORT_RECOVERY_STABLE_TICKS ||
Date.now() - startedAt >= VIEWPORT_RECOVERY_MAX_DURATION_MS
) {
return;
}
continueRecoverySync();
}, VIEWPORT_RECOVERY_INTERVAL_MS);
timeoutIds.add(timeoutId);
};
continueRecoverySync();
};
const handleWindowResize = () => {
sync();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
scheduleRecoverySync();
}
};
scheduleRecoverySync();
window.addEventListener('resize', handleWindowResize);
window.addEventListener('orientationchange', scheduleRecoverySync);
window.addEventListener('focus', scheduleRecoverySync);
window.addEventListener('pageshow', scheduleRecoverySync);
viewport?.addEventListener('resize', scheduleRecoverySync);
viewport?.addEventListener('scroll', scheduleRecoverySync);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', sync);
window.removeEventListener('orientationchange', sync);
viewport?.removeEventListener('resize', sync);
viewport?.removeEventListener('scroll', sync);
clearScheduledSyncs();
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('orientationchange', scheduleRecoverySync);
window.removeEventListener('focus', scheduleRecoverySync);
window.removeEventListener('pageshow', scheduleRecoverySync);
viewport?.removeEventListener('resize', scheduleRecoverySync);
viewport?.removeEventListener('scroll', scheduleRecoverySync);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}