chore: exclude local resource artifacts from main sync
This commit is contained in:
0
src/app/main/AppShell.tsx
Executable file → Normal file
0
src/app/main/AppShell.tsx
Executable file → Normal 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
204
src/app/main/ChatTypeManagementPage.tsx
Executable file → Normal 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
0
src/app/main/InitialLoadingOverlay.css
Executable file → Normal file
0
src/app/main/InitialLoadingOverlay.tsx
Executable file → Normal file
0
src/app/main/InitialLoadingOverlay.tsx
Executable file → Normal file
20
src/app/main/MainChatPanel.css
Executable file → Normal file
20
src/app/main/MainChatPanel.css
Executable file → Normal 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
155
src/app/main/MainContent.tsx
Executable file → Normal 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
447
src/app/main/MainHeader.tsx
Executable file → Normal 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
426
src/app/main/MainLayout.css
Executable file → Normal 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
0
src/app/main/MainSidebar.tsx
Executable file → Normal file
0
src/app/main/MainView.tsx
Executable file → Normal file
0
src/app/main/MainView.tsx
Executable file → Normal file
165
src/app/main/ManagementPage.shared.css
Executable file → Normal file
165
src/app/main/ManagementPage.shared.css
Executable file → Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
437
src/app/main/PreviewAppOverlay.tsx
Normal file
437
src/app/main/PreviewAppOverlay.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
39
src/app/main/PreviewAppWindow.tsx
Normal file
39
src/app/main/PreviewAppWindow.tsx
Normal 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
0
src/app/main/ReleasePendingMainModal.tsx
Executable file → Normal 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
7
src/app/main/appConfig.ts
Executable file → Normal 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
0
src/app/main/appUpdate.ts
Executable file → Normal 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
138
src/app/main/chatTypeAccess.ts
Executable file → Normal 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();
|
||||
|
||||
@@ -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={() => {}}
|
||||
|
||||
@@ -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'
|
||||
|
||||
221
src/app/main/chatV2/hooks/conversationListMerge.ts
Normal file
221
src/app/main/chatV2/hooks/conversationListMerge.ts
Normal 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]);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
56
src/app/main/clientIdentity.ts
Executable file → Normal 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
0
src/app/main/errorLogApi.ts
Executable file → Normal file
0
src/app/main/index.ts
Executable file → Normal file
0
src/app/main/index.ts
Executable file → Normal file
73
src/app/main/layout/MainLayout.tsx
Executable file → Normal file
73
src/app/main/layout/MainLayout.tsx
Executable file → Normal 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
0
src/app/main/layout/MainLayoutContext.ts
Executable file → Normal file
86
src/app/main/layout/buildSearchOptions.ts
Executable file → Normal file
86
src/app/main/layout/buildSearchOptions.ts
Executable file → Normal 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
0
src/app/main/layout/useMainLayoutData.ts
Executable file → Normal 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
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
35
src/app/main/mainChatPanel/ChatPreviewBody.tsx
Executable file → Normal 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') {
|
||||
|
||||
@@ -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
6
src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx
Executable file → Normal 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
0
src/app/main/mainChatPanel/ErrorLogViewer.tsx
Executable file → Normal 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'));
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
34
src/app/main/mainChatPanel/composerFilePickKey.ts
Normal file
34
src/app/main/mainChatPanel/composerFilePickKey.ts
Normal 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}`;
|
||||
}
|
||||
33
src/app/main/mainChatPanel/conversationUnread.ts
Normal file
33
src/app/main/mainChatPanel/conversationUnread.ts
Normal 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
0
src/app/main/mainChatPanel/errorLogUtils.tsx
Executable file → Normal file
0
src/app/main/mainChatPanel/errorLogUtils.types.ts
Executable file → Normal file
0
src/app/main/mainChatPanel/errorLogUtils.types.ts
Executable file → Normal file
@@ -18,9 +18,11 @@ export {
|
||||
deleteChatConversationRoom,
|
||||
fetchChatConversationDetail,
|
||||
fetchChatConversations,
|
||||
fetchChatSourceChanges,
|
||||
fetchChatRuntimeJobDetail,
|
||||
fetchChatRuntimeSnapshot,
|
||||
getStoredChatSessionLastTypeId,
|
||||
mergeConversationRequestStatusMessage,
|
||||
isMissingRequestMessage,
|
||||
isPreparingChatReplyText,
|
||||
getChatClientSessionId,
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
111
src/app/main/mainChatPanel/previewKind.ts
Normal file
111
src/app/main/mainChatPanel/previewKind.ts
Normal 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';
|
||||
}
|
||||
15
src/app/main/mainChatPanel/promptPreviewState.ts
Normal file
15
src/app/main/mainChatPanel/promptPreviewState.ts
Normal 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;
|
||||
}
|
||||
665
src/app/main/mainChatPanel/requestBadgeLabel.ts
Normal file
665
src/app/main/mainChatPanel/requestBadgeLabel.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
61
src/app/main/mainChatPanel/types.ts
Executable file → Normal 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;
|
||||
|
||||
@@ -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
134
src/app/main/mainChatPanel/useChatConnection.ts
Executable file → Normal 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
0
src/app/main/mainChatPanel/useErrorLogs.ts
Executable file → Normal file
22
src/app/main/mainContent/windowLayout.ts
Executable file → Normal file
22
src/app/main/mainContent/windowLayout.ts
Executable file → Normal 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
0
src/app/main/mainView/constants.tsx
Executable file → Normal file
0
src/app/main/mainView/index.ts
Executable file → Normal file
0
src/app/main/mainView/index.ts
Executable file → Normal file
0
src/app/main/mainView/navigation.ts
Executable file → Normal file
0
src/app/main/mainView/navigation.ts
Executable file → Normal file
65
src/app/main/mainView/searchOptions.ts
Executable file → Normal file
65
src/app/main/mainView/searchOptions.ts
Executable file → Normal 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
0
src/app/main/mainView/useMainViewData.ts
Executable file → Normal file
0
src/app/main/mainView/utils.ts
Executable file → Normal file
0
src/app/main/mainView/utils.ts
Executable file → Normal file
92
src/app/main/mobileNavigationGestureBlocker.ts
Normal file
92
src/app/main/mobileNavigationGestureBlocker.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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
96
src/app/main/notificationApi.ts
Executable file → Normal 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
61
src/app/main/notificationIdentity.ts
Executable file → Normal 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
30
src/app/main/pages/ApisPage.tsx
Executable file → Normal 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
0
src/app/main/pages/ChatPage.tsx
Executable file → Normal file
0
src/app/main/pages/DocsPage.tsx
Executable file → Normal file
0
src/app/main/pages/DocsPage.tsx
Executable file → Normal file
0
src/app/main/pages/PlansPage.tsx
Executable file → Normal file
0
src/app/main/pages/PlansPage.tsx
Executable file → Normal file
46
src/app/main/pages/PlayPage.tsx
Executable file → Normal file
46
src/app/main/pages/PlayPage.tsx
Executable file → Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
294
src/app/main/previewRuntime.ts
Normal file
294
src/app/main/previewRuntime.ts
Normal 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
59
src/app/main/routes.tsx
Executable file → Normal 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) {
|
||||
|
||||
53
src/app/main/searchRecent.ts
Normal file
53
src/app/main/searchRecent.ts
Normal 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);
|
||||
}
|
||||
78
src/app/main/themeColorSync.ts
Normal file
78
src/app/main/themeColorSync.ts
Normal 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
73
src/app/main/tokenAccess.ts
Executable file → Normal 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
1
src/app/main/types.ts
Executable file → Normal file
@@ -60,6 +60,7 @@ export type MainSidebarProps = {
|
||||
export type MainContentProps = {
|
||||
contentExpanded: boolean;
|
||||
sidebarOverlayActive?: boolean;
|
||||
disableWindowLayer?: boolean;
|
||||
onToggleContentExpanded: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user