feat: refine codex live chat context flows
This commit is contained in:
1
src/app/main/AutomationContextManagementPage.css
Normal file
1
src/app/main/AutomationContextManagementPage.css
Normal file
@@ -0,0 +1 @@
|
||||
@import './ManagementPage.shared.css';
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useAutomationContextRegistry,
|
||||
} from './automationContextAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
import './AutomationContextManagementPage.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
|
||||
1
src/app/main/AutomationTypeManagementPage.css
Normal file
1
src/app/main/AutomationTypeManagementPage.css
Normal file
@@ -0,0 +1 @@
|
||||
@import './ManagementPage.shared.css';
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type AutomationTypeRecord,
|
||||
} from './automationTypeAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
import './AutomationTypeManagementPage.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
|
||||
1
src/app/main/ChatDefaultContextManagementPage.css
Normal file
1
src/app/main/ChatDefaultContextManagementPage.css
Normal file
@@ -0,0 +1 @@
|
||||
@import './ManagementPage.shared.css';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ChatDefaultContextRecord,
|
||||
} from './chatContextSettingsAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
import './ChatDefaultContextManagementPage.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -55,7 +55,13 @@ export function ChatDefaultContextManagementPage() {
|
||||
defaultContexts,
|
||||
chatTypeDefaults,
|
||||
roomContexts,
|
||||
isLoading,
|
||||
hasLoadedFromServer,
|
||||
storeSource,
|
||||
lastLoadedAt,
|
||||
lastFailedAt,
|
||||
errorMessage: contextSettingsErrorMessage,
|
||||
reload,
|
||||
setStore,
|
||||
} = useChatContextSettingsRegistry();
|
||||
const [selectedContextId, setSelectedContextId] = useState<string | null>(defaultContexts[0]?.id ?? null);
|
||||
@@ -65,12 +71,15 @@ export function ChatDefaultContextManagementPage() {
|
||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [form] = Form.useForm<ChatDefaultContextFormValue>();
|
||||
|
||||
const selectedContext = useMemo(
|
||||
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
|
||||
[defaultContexts, selectedContextId],
|
||||
);
|
||||
const shouldRenderServerList = hasLoadedFromServer && !contextSettingsErrorMessage;
|
||||
const isServerDataReadyForEditing = hasLoadedFromServer && !contextSettingsErrorMessage && storeSource === 'server';
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedContextId && defaultContexts.some((item) => item.id === selectedContextId)) {
|
||||
@@ -181,58 +190,113 @@ export function ChatDefaultContextManagementPage() {
|
||||
title="기본 유형 관리"
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm} disabled={!isServerDataReadyForEditing}>
|
||||
신규 기본 유형
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 기본 유형</Title>
|
||||
<Text type="secondary">{`${defaultContexts.length}건`}</Text>
|
||||
</div>
|
||||
{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>
|
||||
<div className="chat-type-management-page__list-scroll">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
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);
|
||||
});
|
||||
}}
|
||||
loading={isReloading}
|
||||
>
|
||||
다시 불러오기
|
||||
</Button>
|
||||
{lastLoadedAt ? (
|
||||
<Text type="secondary">{`마지막 정상 동기화: ${new Date(lastLoadedAt).toLocaleString()}`}</Text>
|
||||
) : (
|
||||
<Text type="secondary">정상 동기화 이력이 없어 부분 목록을 대신 보여주지 않습니다.</Text>
|
||||
)}
|
||||
{lastFailedAt ? (
|
||||
<Text type="secondary">{`마지막 실패: ${new Date(lastFailedAt).toLocaleString()}`}</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 기본 유형</Title>
|
||||
<Space size={8} wrap>
|
||||
<Text type="secondary">{shouldRenderServerList ? `${defaultContexts.length}건` : '서버 확인 전'}</Text>
|
||||
{isLoading ? <Text type="secondary">서버 동기화 중</Text> : null}
|
||||
{shouldRenderServerList && lastLoadedAt ? (
|
||||
<Text type="secondary">{`서버 기준 ${new Date(lastLoadedAt).toLocaleTimeString()}`}</Text>
|
||||
) : null}
|
||||
{!shouldRenderServerList && !contextSettingsErrorMessage ? (
|
||||
<Text type="secondary">표시는 `/api/chat-context-settings` 최신 응답 기준입니다.</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
{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} /> : '본문 없음'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 기본 유형이 없습니다." />
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : isLoading && !hasLoadedFromServer ? (
|
||||
<Alert showIcon type="info" message="기본 유형 목록을 서버에서 불러오는 중입니다." />
|
||||
) : (
|
||||
<Empty
|
||||
description={
|
||||
contextSettingsErrorMessage
|
||||
? '서버 동기화 실패 상태입니다. 재조회 후 다시 확인해 주세요.'
|
||||
: hasLoadedFromServer
|
||||
? '등록된 기본 유형이 없습니다.'
|
||||
: '서버 기준 기본 유형을 아직 확인하지 못했습니다.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -248,17 +312,25 @@ export function ChatDefaultContextManagementPage() {
|
||||
shape="circle"
|
||||
icon={<SaveOutlined />}
|
||||
aria-label={isCreating ? '등록' : '수정 저장'}
|
||||
disabled={!isServerDataReadyForEditing}
|
||||
onClick={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
/>
|
||||
<Button shape="circle" icon={<PlusOutlined />} aria-label="새 입력" onClick={openCreateForm} />
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label="새 입력"
|
||||
onClick={openCreateForm}
|
||||
disabled={!isServerDataReadyForEditing}
|
||||
/>
|
||||
{!isCreating && selectedContext ? (
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="삭제"
|
||||
disabled={!isServerDataReadyForEditing}
|
||||
onClick={() => {
|
||||
void handleDelete();
|
||||
}}
|
||||
@@ -271,6 +343,14 @@ export function ChatDefaultContextManagementPage() {
|
||||
<div className="chat-type-management-page__editor">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
{!isServerDataReadyForEditing ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="서버 최신 기본 유형을 확인한 뒤에만 수정할 수 있습니다."
|
||||
description="부분 목록이나 오래된 상태로 저장해 서버 값이 덮어써지는 경로를 막기 위해, 서버 동기화가 완료되기 전에는 저장을 제한합니다."
|
||||
/>
|
||||
) : null}
|
||||
<Form
|
||||
className="chat-type-management-page__editor-form"
|
||||
layout="vertical"
|
||||
|
||||
653
src/app/main/ChatTypeManagementPage.css
Executable file → Normal file
653
src/app/main/ChatTypeManagementPage.css
Executable file → Normal file
@@ -1,652 +1 @@
|
||||
.chat-type-management-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__card {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 4px 14px 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list .ant-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: auto;
|
||||
padding: 0 0 calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll:has(.chat-type-management-page__markdown-grid--maximized) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form .ant-form-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header .ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item {
|
||||
cursor: pointer;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item--active {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description.ant-typography {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-field--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-options {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-option-copy {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-field {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__field-label {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions .ant-btn {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid--maximized {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: min(720px, calc(100dvh - 236px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane--desktop-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-input-textarea,
|
||||
.chat-type-management-page__markdown-pane .ant-input {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea {
|
||||
height: 100% !important;
|
||||
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: 100% !important;
|
||||
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
||||
overflow: auto !important;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item .ant-form-item-label {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item .ant-form-item-control-input {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--permissions .ant-checkbox-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--permissions .ant-checkbox-wrapper {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--name {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__field-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__card--pane-maximized .ant-card-body {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.chat-type-management-page,
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card,
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor,
|
||||
.chat-type-management-page__editor-form,
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page__markdown-preview {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head {
|
||||
min-height: 48px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra,
|
||||
.chat-type-management-page .ant-card-body {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll {
|
||||
gap: 3px;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__mobile-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-textarea),
|
||||
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-preview) {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane--mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page__markdown-preview {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page__markdown-textarea textarea,
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea {
|
||||
height: 100% !important;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-preview {
|
||||
min-height: clamp(220px, calc(100dvh - 560px), 320px);
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-preview {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input-content {
|
||||
flex: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: auto !important;
|
||||
min-height: clamp(320px, calc(100dvh - 430px), 520px) !important;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized {
|
||||
height: calc(100dvh - 52px);
|
||||
max-height: calc(100dvh - 52px);
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-head {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-head-title,
|
||||
.chat-type-management-page--pane-maximized .ant-card-extra {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-body {
|
||||
padding: 4px 8px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__card,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-preview,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea textarea {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
||||
gap: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||
height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
min-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input-content {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions .ant-btn {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
@import './ManagementPage.shared.css';
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
EditOutlined,
|
||||
SaveOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ShrinkOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -61,7 +62,7 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
|
||||
|
||||
export function ChatTypeManagementPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
|
||||
const { chatTypes, builtInChatTypes, customChatTypes, setChatTypes, isLoading, errorMessage, reload } = useChatTypeRegistry();
|
||||
const {
|
||||
defaultContexts,
|
||||
chatTypeDefaults,
|
||||
@@ -76,6 +77,7 @@ export function ChatTypeManagementPage() {
|
||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]);
|
||||
const [form] = Form.useForm<ChatTypeFormValue>();
|
||||
@@ -83,17 +85,17 @@ export function ChatTypeManagementPage() {
|
||||
const isPaneMaximized = maximizedPane !== 'none';
|
||||
|
||||
const selectedChatType = useMemo(
|
||||
() => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
|
||||
[chatTypes, selectedChatTypeId],
|
||||
() => customChatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
|
||||
[customChatTypes, selectedChatTypeId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChatTypeId && chatTypes.some((item) => item.id === selectedChatTypeId)) {
|
||||
if (selectedChatTypeId && customChatTypes.some((item) => item.id === selectedChatTypeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedChatTypeId(chatTypes[0]?.id ?? null);
|
||||
}, [chatTypes, selectedChatTypeId]);
|
||||
setSelectedChatTypeId(customChatTypes[0]?.id ?? null);
|
||||
}, [customChatTypes, selectedChatTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
@@ -198,8 +200,8 @@ export function ChatTypeManagementPage() {
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
||||
setSelectedChatTypeId(savedChatTypes[0]?.id ?? null);
|
||||
const savedSnapshot = await setChatTypes(nextChatTypes);
|
||||
setSelectedChatTypeId(savedSnapshot.customChatTypes[0]?.id ?? null);
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
form.resetFields();
|
||||
@@ -211,6 +213,19 @@ export function ChatTypeManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReload = async () => {
|
||||
setIsReloading(true);
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
await reload();
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 재조회에 실패했습니다.');
|
||||
} finally {
|
||||
setIsReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const detailHeaderActions = (
|
||||
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
|
||||
@@ -259,12 +274,12 @@ export function ChatTypeManagementPage() {
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -277,94 +292,150 @@ export function ChatTypeManagementPage() {
|
||||
}`}
|
||||
>
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="컨텍스트 권한 관리"
|
||||
<Card
|
||||
title="채팅유형 관리"
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 컨텍스트
|
||||
</Button>
|
||||
}
|
||||
extra={!isMobileViewport ? (
|
||||
<Space size={8} wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void handleReload()} loading={isReloading}>
|
||||
재조회
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 채팅유형
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 컨텍스트</Title>
|
||||
<Text type="secondary">{isLoading ? '불러오는 중' : `${chatTypes.length}건`}</Text>
|
||||
<Title level={5}>사용자 채팅유형</Title>
|
||||
<Text type="secondary">{isLoading ? '불러오는 중' : `${customChatTypes.length}건`}</Text>
|
||||
</div>
|
||||
{isMobileViewport ? (
|
||||
<Space size={8} wrap className="chat-type-management-page__list-actions">
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void handleReload()} loading={isReloading}>
|
||||
재조회
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 채팅유형
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
{chatTypes.length > 0 ? (
|
||||
<List
|
||||
dataSource={chatTypes}
|
||||
renderItem={(item) => {
|
||||
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
||||
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
|
||||
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
|
||||
.filter((context): context is NonNullable<typeof context> => Boolean(context));
|
||||
const itemClassName =
|
||||
item.id === selectedChatTypeId
|
||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||
: 'chat-type-management-page__item';
|
||||
<div className="chat-type-management-page__list-scroll">
|
||||
{builtInChatTypes.length > 0 ? (
|
||||
<>
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="내장 기본 채팅유형은 코드 기준 고정값입니다."
|
||||
description="이 목록은 참조용이며 여기서 수정·삭제되지 않습니다. 추가/삭제 가능한 대상은 아래 사용자 채팅유형입니다."
|
||||
/>
|
||||
<List
|
||||
dataSource={builtInChatTypes}
|
||||
renderItem={(item) => {
|
||||
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
||||
|
||||
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);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="chat-type-management-page__item-main">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.name}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.description ? (
|
||||
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
||||
) : (
|
||||
'기본 문맥 설명 없음'
|
||||
)}
|
||||
</div>
|
||||
<Space size={[6, 6]} wrap>
|
||||
{item.permissions.map((permission) => (
|
||||
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
||||
))}
|
||||
{linkedDefaultContexts.map((context) => (
|
||||
<Tag key={`${item.id}-${context.id}`} color="gold">
|
||||
{context.title}
|
||||
return (
|
||||
<List.Item className="chat-type-management-page__item">
|
||||
<div className="chat-type-management-page__item-main">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.name}</Text>
|
||||
<Tag color="gold">내장 기본유형</Tag>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.description ? (
|
||||
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
||||
) : (
|
||||
'기본 문맥 설명 없음'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{customChatTypes.length > 0 ? (
|
||||
<List
|
||||
dataSource={customChatTypes}
|
||||
renderItem={(item) => {
|
||||
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
||||
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
|
||||
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
|
||||
.filter((context): context is NonNullable<typeof context> => Boolean(context));
|
||||
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);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="chat-type-management-page__item-main">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.name}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 컨텍스트가 없습니다." />
|
||||
)}
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.description ? (
|
||||
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
|
||||
) : (
|
||||
'기본 문맥 설명 없음'
|
||||
)}
|
||||
</div>
|
||||
<Space size={[6, 6]} wrap>
|
||||
{item.permissions.map((permission) => (
|
||||
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
||||
))}
|
||||
{linkedDefaultContexts.map((context) => (
|
||||
<Tag key={`${item.id}-${context.id}`} color="gold">
|
||||
{context.title}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 사용자 채팅유형이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
|
||||
title={isCreating ? '채팅유형 등록' : '채팅유형 상세'}
|
||||
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
|
||||
extra={detailHeaderActions}
|
||||
>
|
||||
@@ -384,8 +455,10 @@ export function ChatTypeManagementPage() {
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
||||
const savedChatType = savedChatTypes.find((item) => item.id === values.id || item.name === values.name);
|
||||
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 nextChatTypeDefaults = savedChatType
|
||||
? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds)
|
||||
: chatTypeDefaults;
|
||||
@@ -413,9 +486,9 @@ export function ChatTypeManagementPage() {
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="컨텍스트명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
||||
rules={[{ required: true, message: '채팅유형명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: 운영 문의" />
|
||||
<Input placeholder="예: 운영 문의 전용" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ import {
|
||||
DeleteOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Checkbox, Drawer, Empty, Input, Modal, Radio, Space, Tabs, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import { Alert, Button, Card, Checkbox, Drawer, Empty, Input, Modal, Radio, Select, Space, Tabs, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import {
|
||||
@@ -66,7 +66,12 @@ import {
|
||||
type ChatDefaultContextRecord,
|
||||
} from './chatContextSettingsAccess';
|
||||
import { renderModalWithEnterConfirm } from './modalKeyboard';
|
||||
import { createNotificationMessage } from './notificationApi';
|
||||
import {
|
||||
createNotificationMessage,
|
||||
sendClientNotification,
|
||||
shouldFallbackToLocalNotification,
|
||||
showLocalClientNotification,
|
||||
} from './notificationApi';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import {
|
||||
ChatConversationView,
|
||||
@@ -174,6 +179,14 @@ const CHAT_RESTART_EXCLUSION_PATTERNS = [
|
||||
] as const;
|
||||
const CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS = [0, 350, 900, 1800] as const;
|
||||
|
||||
function areStringListsEqual(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
@@ -299,6 +312,56 @@ function buildChatSessionLink(sessionId: string) {
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function buildChatSessionTargetUrl(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId || typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const url = new URL('/chat/live', window.location.origin);
|
||||
url.searchParams.set('topMenu', 'chat');
|
||||
url.searchParams.set('sessionId', normalizedSessionId);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function createChatQuestionAnswerNotificationBody(args: {
|
||||
questionText?: string | null;
|
||||
answerText?: string | null;
|
||||
fallback: string;
|
||||
}) {
|
||||
const questionPreview = createConversationPreviewText(args.questionText ?? '');
|
||||
const answerPreview = createConversationPreviewText(args.answerText ?? '');
|
||||
|
||||
if (questionPreview && answerPreview) {
|
||||
return `질문: ${questionPreview}\n답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (answerPreview) {
|
||||
return `답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (questionPreview) {
|
||||
return `질문: ${questionPreview}`;
|
||||
}
|
||||
|
||||
return args.fallback;
|
||||
}
|
||||
|
||||
async function showLocalChatNotification(args: {
|
||||
title: string;
|
||||
body: string;
|
||||
threadId: string;
|
||||
data: Record<string, string>;
|
||||
}) {
|
||||
await showLocalClientNotification({
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
threadId: args.threadId,
|
||||
data: args.data,
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
@@ -389,6 +452,8 @@ function buildOptimisticConversationSummary(args: {
|
||||
currentJobMessage: null,
|
||||
currentQueueSize: 0,
|
||||
currentStatusUpdatedAt: null,
|
||||
isPendingWork: false,
|
||||
pendingWorkReason: null,
|
||||
lastRequestPreview: '',
|
||||
lastMessagePreview: '',
|
||||
lastResponsePreview: '',
|
||||
@@ -711,6 +776,106 @@ function resolveConversationListPreviewText(preview: string) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const LOCAL_PENDING_WORK_ANALYSIS_PATTERNS = [
|
||||
/분석/u,
|
||||
/검토/u,
|
||||
/조사/u,
|
||||
/원인/u,
|
||||
/파악/u,
|
||||
/\banalysis\b/i,
|
||||
/\binvestigat(?:e|ion)\b/i,
|
||||
] as const;
|
||||
|
||||
const LOCAL_PENDING_WORK_DESIGN_PATTERNS = [
|
||||
/설계/u,
|
||||
/프롬프트/u,
|
||||
/시안/u,
|
||||
/구조/u,
|
||||
/방향/u,
|
||||
/기획/u,
|
||||
/플로우/u,
|
||||
/아키텍처/u,
|
||||
/\bdesign\b/i,
|
||||
/\barchitecture\b/i,
|
||||
] as const;
|
||||
|
||||
const LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS = [
|
||||
/구현했/u,
|
||||
/수정했/u,
|
||||
/반영했/u,
|
||||
/적용했/u,
|
||||
/완료했/u,
|
||||
/마무리했/u,
|
||||
/배포했/u,
|
||||
/검증했/u,
|
||||
/빌드.*통과/u,
|
||||
/테스트.*통과/u,
|
||||
/캡처/u,
|
||||
/preview/iu,
|
||||
/변경 파일/u,
|
||||
/diff/u,
|
||||
/\bimplement(?:ed|ation)?\b/i,
|
||||
/\bfix(?:ed)?\b/i,
|
||||
/\bverified?\b/i,
|
||||
/\btested?\b/i,
|
||||
] as const;
|
||||
|
||||
const LOCAL_PENDING_WORK_RESPONSE_HOLD_PATTERNS = [
|
||||
/원하시면/u,
|
||||
/진행해드릴/u,
|
||||
/이어(?:서|가)/u,
|
||||
/다음 단계/u,
|
||||
/선택/u,
|
||||
/옵션/u,
|
||||
/후속/u,
|
||||
/\bif you want\b/i,
|
||||
/\bnext step\b/i,
|
||||
] as const;
|
||||
|
||||
function normalizeConversationPendingWorkText(text: string | null | undefined) {
|
||||
return String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function hasConversationPendingWorkPattern(text: string, patterns: readonly RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function inferConversationPendingWorkReason(item: ChatConversationSummary) {
|
||||
if (item.pendingWorkReason) {
|
||||
return item.pendingWorkReason;
|
||||
}
|
||||
|
||||
const requestText = normalizeConversationPendingWorkText(item.lastRequestPreview);
|
||||
const responseText = normalizeConversationPendingWorkText(item.lastResponsePreview);
|
||||
|
||||
if (
|
||||
hasConversationPendingWorkPattern(requestText, LOCAL_PENDING_WORK_DESIGN_PATTERNS) &&
|
||||
!hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS)
|
||||
) {
|
||||
return 'design' as const;
|
||||
}
|
||||
|
||||
if (
|
||||
hasConversationPendingWorkPattern(requestText, LOCAL_PENDING_WORK_ANALYSIS_PATTERNS) &&
|
||||
!hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS)
|
||||
) {
|
||||
return 'analysis' as const;
|
||||
}
|
||||
|
||||
if (
|
||||
hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_DESIGN_PATTERNS) ||
|
||||
hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_ANALYSIS_PATTERNS)
|
||||
) {
|
||||
if (hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_RESPONSE_HOLD_PATTERNS)) {
|
||||
return hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_DESIGN_PATTERNS)
|
||||
? 'design'
|
||||
: 'analysis';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function trimConversationRequestBadgeLabel(label: string, maxLength = 18) {
|
||||
const normalized = label.replace(/\s+/g, ' ').trim();
|
||||
|
||||
@@ -1244,6 +1409,32 @@ function buildMessageSyncKey(messages: ChatMessage[]) {
|
||||
return `${messages.length}:${latestMessage.id}:${latestMessage.text.length}:${latestMessage.timestamp}`;
|
||||
}
|
||||
|
||||
const CHAT_CONVERSATION_DETAIL_PAGE_SIZE = 8;
|
||||
|
||||
function collectVisibleConversationRequestIds(messages: ChatMessage[]) {
|
||||
return new Set(
|
||||
messages
|
||||
.map((message) => message.clientRequestId?.trim() ?? '')
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function countVisibleConversationRequests(
|
||||
messages: ChatMessage[],
|
||||
requestItems: ChatConversationRequest[],
|
||||
sessionId: string,
|
||||
) {
|
||||
const visibleRequestIds = collectVisibleConversationRequestIds(messages);
|
||||
|
||||
if (visibleRequestIds.size === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return requestItems.filter(
|
||||
(item) => item.sessionId === sessionId && item.status !== 'removed' && visibleRequestIds.has(item.requestId),
|
||||
).length;
|
||||
}
|
||||
|
||||
function buildAttachmentMessageBlock(attachments: ChatComposerAttachment[]) {
|
||||
if (attachments.length === 0) {
|
||||
return '';
|
||||
@@ -1689,6 +1880,8 @@ function mergeConversationSummaryPreservingChatType(
|
||||
generalSectionName: normalizeGeneralSectionName(nextItem.generalSectionName) ?? normalizeGeneralSectionName(previousItem.generalSectionName),
|
||||
contextLabel: nextItem.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
|
||||
contextDescription: nextItem.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
|
||||
isPendingWork: nextItem.isPendingWork ?? previousItem.isPendingWork ?? false,
|
||||
pendingWorkReason: nextItem.pendingWorkReason ?? previousItem.pendingWorkReason ?? null,
|
||||
lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '',
|
||||
lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||
lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
|
||||
@@ -1824,6 +2017,14 @@ function applyRuntimeSnapshotToConversationItems(
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
function isConversationPendingWork(item: ChatConversationSummary) {
|
||||
if (isConversationProcessing(item) || isConversationFailed(item) || item.hasUnreadResponse) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.isPendingWork === true || inferConversationPendingWorkReason(item) != null;
|
||||
}
|
||||
|
||||
function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
@@ -1859,7 +2060,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const { chatTypes, setChatTypes } = useChatTypeRegistry();
|
||||
const { defaultContexts, chatTypeDefaults, roomContexts, setStore: setChatContextSettingsStore } =
|
||||
useChatContextSettingsRegistry();
|
||||
const [draft, setDraft] = useState('');
|
||||
const draftRef = useRef('');
|
||||
const [draftSeed, setDraftSeed] = useState({ value: '', version: 0 });
|
||||
const [composerAttachments, setComposerAttachments] = useState<ChatComposerAttachment[]>([]);
|
||||
const [isComposerAttachmentUploading, setIsComposerAttachmentUploading] = useState(false);
|
||||
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
||||
@@ -1889,6 +2091,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState<string | null>(null);
|
||||
const [editingChatTypeDescription, setEditingChatTypeDescription] = useState('');
|
||||
const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState<string[]>([]);
|
||||
const [isEditingRoomDefaultContextsDirty, setIsEditingRoomDefaultContextsDirty] = useState(false);
|
||||
const [editingRoomCustomContextTitle, setEditingRoomCustomContextTitle] = useState('');
|
||||
const [editingRoomCustomContextContent, setEditingRoomCustomContextContent] = useState('');
|
||||
const [mobileConversationSectionOpen, setMobileConversationSectionOpen] =
|
||||
@@ -1945,6 +2148,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false);
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
|
||||
const [pendingClearConversationSessionId, setPendingClearConversationSessionId] = useState<string | null>(null);
|
||||
const [conversationProcessingNow, setConversationProcessingNow] = useState(() => Date.now());
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<TextAreaRef | null>(null);
|
||||
@@ -1975,9 +2179,27 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const isClosingConversationRef = useRef(false);
|
||||
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
||||
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
|
||||
const notifiedChatPushKeysRef = useRef<string[]>([]);
|
||||
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
||||
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
|
||||
const isCreatingImportedDraftConversationRef = useRef(false);
|
||||
const setDraft = useCallback((value: string) => {
|
||||
draftRef.current = value;
|
||||
}, []);
|
||||
const setDraftValue = useCallback((value: string) => {
|
||||
const shouldRefreshComposer = draftRef.current !== value;
|
||||
draftRef.current = value;
|
||||
setDraftSeed((previous) => {
|
||||
if (previous.value === value && !shouldRefreshComposer) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
version: previous.version + 1,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
|
||||
setRequestItemsState((previous) => {
|
||||
const safePrevious = Array.isArray(previous) ? previous : [];
|
||||
@@ -2019,10 +2241,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft((previous) => (previous.trim() ? previous : queuedImportedDraft));
|
||||
setDraftValue(draftRef.current.trim() ? draftRef.current : queuedImportedDraft);
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
setQueuedImportedDraft('');
|
||||
}, [activeSessionId, queuedImportedDraft]);
|
||||
}, [activeSessionId, queuedImportedDraft, setDraftValue]);
|
||||
|
||||
const {
|
||||
conversationItems,
|
||||
@@ -2036,6 +2258,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
enabled: activeView === 'chat',
|
||||
});
|
||||
const conversationItemsRef = useRef<ChatConversationSummary[]>(conversationItems);
|
||||
const activeConversation = useMemo(
|
||||
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
||||
[activeSessionId, conversationItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
setConversationItems((previous) => {
|
||||
const storedSectionNameMap = readStoredGeneralSectionNameMap();
|
||||
@@ -2293,7 +2519,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
null;
|
||||
setEditingRoomChatTypeId(nextChatTypeId);
|
||||
setEditingChatTypeDescription(nextChatType?.description ?? '');
|
||||
setEditingRoomDefaultContextIds(effectiveDefaultContextIds);
|
||||
setEditingRoomDefaultContextIds(activeRoomContextSettings?.defaultContextIds ?? resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatTypeId));
|
||||
setIsEditingRoomDefaultContextsDirty(false);
|
||||
setEditingRoomCustomContextTitle(effectiveRoomCustomContextTitle);
|
||||
setEditingRoomCustomContextContent(effectiveRoomCustomContextContent);
|
||||
setContextDrawerTabKey('chat-type');
|
||||
@@ -2327,32 +2554,47 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
permissions: nextChatType.permissions,
|
||||
enabled: nextChatType.enabled,
|
||||
});
|
||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
||||
nextChatType = savedChatTypes.find((item) => item.id === targetChatTypeId) ?? nextChatType;
|
||||
const savedSnapshot = await setChatTypes(nextChatTypes);
|
||||
nextChatType = savedSnapshot.chatTypes.find((item) => item.id === targetChatTypeId) ?? nextChatType;
|
||||
}
|
||||
|
||||
const resolvedChatType = nextChatType;
|
||||
const normalizedDefaultContextIds = Array.from(
|
||||
new Set(
|
||||
editingRoomDefaultContextIds
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => enabledDefaultContexts.some((context) => context.id === value)),
|
||||
),
|
||||
);
|
||||
const nextCustomContextTitle = editingRoomCustomContextTitle.trim();
|
||||
const nextCustomContextContent = editingRoomCustomContextContent.trim();
|
||||
const inheritedDefaultContextIds = resolveChatTypeDefaultContextIds(chatTypeDefaults, resolvedChatType.id);
|
||||
const shouldPersistRoomDefaultContextIds = !areStringListsEqual(normalizedDefaultContextIds, inheritedDefaultContextIds);
|
||||
const shouldPersistRoomCustomContext = Boolean(nextCustomContextTitle || nextCustomContextContent);
|
||||
|
||||
const nextRoomContexts = upsertChatRoomContextSettings(roomContexts, {
|
||||
sessionId: activeConversation.sessionId,
|
||||
defaultContextIds: editingRoomDefaultContextIds,
|
||||
customContextTitle: editingRoomCustomContextTitle,
|
||||
customContextContent: editingRoomCustomContextContent,
|
||||
});
|
||||
const nextRoomContexts =
|
||||
shouldPersistRoomDefaultContextIds || shouldPersistRoomCustomContext
|
||||
? upsertChatRoomContextSettings(roomContexts, {
|
||||
sessionId: activeConversation.sessionId,
|
||||
defaultContextIds: normalizedDefaultContextIds,
|
||||
customContextTitle: nextCustomContextTitle,
|
||||
customContextContent: nextCustomContextContent,
|
||||
})
|
||||
: roomContexts.filter((item) => item.sessionId !== activeConversation.sessionId);
|
||||
const nextDescription = normalizeConversationContextDescription(
|
||||
resolveComposedChatTypeDescription(resolvedChatType, {
|
||||
sessionId: activeConversation.sessionId,
|
||||
defaultContextIds: editingRoomDefaultContextIds,
|
||||
customContextTitle: editingRoomCustomContextTitle,
|
||||
customContextContent: editingRoomCustomContextContent,
|
||||
defaultContextIds: normalizedDefaultContextIds,
|
||||
customContextTitle: nextCustomContextTitle,
|
||||
customContextContent: nextCustomContextContent,
|
||||
}),
|
||||
);
|
||||
|
||||
void setChatContextSettingsStore({
|
||||
await setChatContextSettingsStore({
|
||||
defaultContexts,
|
||||
chatTypeDefaults,
|
||||
roomContexts: nextRoomContexts,
|
||||
}).catch(() => {});
|
||||
});
|
||||
setConversationItems((previous) =>
|
||||
previous.map((entry) =>
|
||||
entry.sessionId === activeConversation.sessionId
|
||||
@@ -2376,6 +2618,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
});
|
||||
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
|
||||
setStoredChatSessionLastTypeId(activeConversation.sessionId, resolvedChatType.id);
|
||||
setIsEditingRoomDefaultContextsDirty(false);
|
||||
setIsContextDrawerOpen(false);
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '채팅방 Context 저장에 실패했습니다.');
|
||||
@@ -2396,7 +2639,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return nextItems;
|
||||
});
|
||||
};
|
||||
const syncConversationPreviewForRequest = (sessionId: string, text: string, requestedAt = new Date().toISOString()) => {
|
||||
const syncConversationPreviewForRequest = (
|
||||
sessionId: string,
|
||||
text: string,
|
||||
requestedAt = new Date().toISOString(),
|
||||
options?: {
|
||||
requestId?: string;
|
||||
mode?: 'queue' | 'direct';
|
||||
queueSize?: number;
|
||||
jobMessage?: string | null;
|
||||
},
|
||||
) => {
|
||||
const nextPreview = createConversationPreviewText(text);
|
||||
|
||||
if (!nextPreview) {
|
||||
@@ -2413,6 +2666,26 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
lastMessagePreview: nextPreview,
|
||||
lastMessageAt: requestedAt,
|
||||
updatedAt: requestedAt,
|
||||
currentRequestId: options?.requestId?.trim() || item.currentRequestId,
|
||||
currentJobStatus:
|
||||
options?.mode === 'queue'
|
||||
? 'queued'
|
||||
: options?.mode === 'direct'
|
||||
? 'started'
|
||||
: item.currentJobStatus,
|
||||
currentJobMessage:
|
||||
options?.jobMessage?.trim() ||
|
||||
(options?.mode === 'queue'
|
||||
? '대기열 등록 중'
|
||||
: options?.mode === 'direct'
|
||||
? '즉시 요청 실행 대기 중'
|
||||
: item.currentJobMessage),
|
||||
currentQueueSize:
|
||||
options?.mode === 'queue' ? Math.max(1, Number(options?.queueSize ?? 1)) : item.currentQueueSize,
|
||||
currentStatusUpdatedAt:
|
||||
options?.mode === 'queue' || options?.mode === 'direct'
|
||||
? requestedAt
|
||||
: item.currentStatusUpdatedAt,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
@@ -2486,18 +2759,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
const syncToken = ++conversationDetailSyncTokenRef.current;
|
||||
const activeSessionRequestCount = requestItemsRef.current.filter(
|
||||
(item) => item.sessionId === normalizedSessionId,
|
||||
).length;
|
||||
const activeSessionVisibleRequestCount =
|
||||
const visibleMessages =
|
||||
normalizedSessionId === activeSessionId
|
||||
? requestItemsRef.current.filter(
|
||||
(item) => item.sessionId === normalizedSessionId && item.status !== 'removed',
|
||||
).length
|
||||
: 0;
|
||||
? messagesRef.current
|
||||
: getCachedSessionMessages(sessionMessageCacheRef.current, normalizedSessionId);
|
||||
const visibleRequestCount = countVisibleConversationRequests(
|
||||
visibleMessages,
|
||||
requestItemsRef.current,
|
||||
normalizedSessionId,
|
||||
);
|
||||
const detailLimit = Math.min(
|
||||
60,
|
||||
Math.max(20, activeSessionRequestCount || 0, activeSessionVisibleRequestCount || 0),
|
||||
Math.max(CHAT_CONVERSATION_DETAIL_PAGE_SIZE, visibleRequestCount),
|
||||
);
|
||||
|
||||
for (const delayMs of CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS) {
|
||||
@@ -2824,6 +3097,78 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chatNotificationKey = `${sessionId}:${incomingMessage.clientRequestId ?? incomingMessage.id}:codex-response`;
|
||||
|
||||
if (notifiedChatPushKeysRef.current.includes(chatNotificationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifiedChatPushKeysRef.current = [...notifiedChatPushKeysRef.current, chatNotificationKey].slice(-80);
|
||||
|
||||
const conversationTitle = eventConversation?.title?.trim() || '현재 채팅방';
|
||||
const targetUrl = buildChatSessionTargetUrl(sessionId);
|
||||
const notificationTitle = `${conversationTitle} 새 답변`;
|
||||
const notificationBody = createChatQuestionAnswerNotificationBody({
|
||||
questionText: relatedQuestionText,
|
||||
answerText: incomingMessage.text,
|
||||
fallback: `${conversationTitle}에 새 답변이 도착했습니다.`,
|
||||
});
|
||||
const notificationData = {
|
||||
category: 'chat',
|
||||
priority: 'normal',
|
||||
sessionId,
|
||||
conversationTitle,
|
||||
requestId: incomingMessage.clientRequestId ?? '',
|
||||
questionText: relatedQuestionText,
|
||||
answerText: incomingMessage.text,
|
||||
targetUrl,
|
||||
linkUrl: targetUrl,
|
||||
linkLabel: '채팅 바로 열기',
|
||||
};
|
||||
const serializedNotificationData = Object.fromEntries(
|
||||
Object.entries(notificationData).flatMap(([key, value]) => (value ? [[key, String(value)]] : [])),
|
||||
);
|
||||
|
||||
void Promise.allSettled([
|
||||
createNotificationMessage({
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
category: 'chat',
|
||||
source: 'codex-live',
|
||||
priority: 'normal',
|
||||
metadata: {
|
||||
...notificationData,
|
||||
previewText: `새 답변 · ${conversationTitle}`,
|
||||
},
|
||||
}),
|
||||
sendClientNotification({
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
threadId: `chat:${sessionId}`,
|
||||
data: serializedNotificationData,
|
||||
}),
|
||||
]).then(async ([storedResult, pushResult]) => {
|
||||
if (pushResult.status === 'rejected') {
|
||||
await showLocalChatNotification({
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
threadId: `chat:${sessionId}`,
|
||||
data: serializedNotificationData,
|
||||
});
|
||||
} else if (shouldFallbackToLocalNotification(pushResult.value)) {
|
||||
await showLocalChatNotification({
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
threadId: `chat:${sessionId}`,
|
||||
data: serializedNotificationData,
|
||||
});
|
||||
}
|
||||
|
||||
if (storedResult.status === 'rejected' && pushResult.status === 'rejected') {
|
||||
notifiedChatPushKeysRef.current = notifiedChatPushKeysRef.current.filter((key) => key !== chatNotificationKey);
|
||||
}
|
||||
});
|
||||
};
|
||||
const previewItems = useMemo(
|
||||
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message) && !isMissingRequestMessage(message))),
|
||||
@@ -2836,10 +3181,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
[messages],
|
||||
);
|
||||
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
|
||||
const activeConversation = useMemo(
|
||||
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
|
||||
[activeSessionId, conversationItems],
|
||||
);
|
||||
const activeConversationHasLocalActivity =
|
||||
chatMessages.length > 0 || requestItems.some((item) => item.sessionId === activeSessionId);
|
||||
const persistedActiveChatTypeId =
|
||||
@@ -3266,6 +3607,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}, [activeGeneralSectionMoveControlsKey, reorderableGeneralSectionKeys]);
|
||||
const pendingDeleteConversation =
|
||||
conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null;
|
||||
const pendingClearConversation =
|
||||
conversationItems.find((item) => item.sessionId === pendingClearConversationSessionId) ?? null;
|
||||
const editingGeneralSectionConversation =
|
||||
conversationItems.find((item) => item.sessionId === editingGeneralSectionSessionId) ?? null;
|
||||
const availableGeneralSectionNames = useMemo(
|
||||
@@ -3280,7 +3623,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
[conversationItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!pendingContextConfirm && !pendingDeleteConversation) {
|
||||
if (!pendingContextConfirm && !pendingDeleteConversation && !pendingClearConversation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3313,7 +3656,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleEnterConfirm, true);
|
||||
};
|
||||
}, [pendingContextConfirm, pendingDeleteConversation]);
|
||||
}, [pendingClearConversation, pendingContextConfirm, pendingDeleteConversation]);
|
||||
const {
|
||||
activePreview,
|
||||
isPreviewLoading,
|
||||
@@ -3643,6 +3986,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
{jobStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{isConversationPendingWork(item) ? (
|
||||
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--request">
|
||||
작업중
|
||||
</span>
|
||||
) : null}
|
||||
{isUnread ? (
|
||||
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--unread">
|
||||
답변 도착
|
||||
@@ -4005,6 +4353,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
currentJobMessage: null,
|
||||
currentQueueSize: 0,
|
||||
currentStatusUpdatedAt: null,
|
||||
isPendingWork: false,
|
||||
pendingWorkReason: null,
|
||||
lastRequestPreview: '',
|
||||
lastMessagePreview: '',
|
||||
lastResponsePreview: '',
|
||||
@@ -4026,7 +4376,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
setMessages(hasCachedMessages ? cachedMessages : []);
|
||||
setRequestItems((previous) => previous.filter((item) => item.sessionId === sessionId));
|
||||
setRequestItems((previous) => {
|
||||
const visibleRequestIds = collectVisibleConversationRequestIds(cachedMessages);
|
||||
|
||||
return previous.filter(
|
||||
(item) =>
|
||||
item.sessionId === sessionId &&
|
||||
(visibleRequestIds.size === 0 ? !hasCachedMessages : visibleRequestIds.has(item.requestId)),
|
||||
);
|
||||
});
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus(null);
|
||||
@@ -4201,6 +4559,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
|
||||
const {
|
||||
cancelPendingRequest,
|
||||
handleClearConversation,
|
||||
deleteStoredRequest,
|
||||
handleDeleteConversation,
|
||||
handleRenameConversation,
|
||||
@@ -4239,6 +4598,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
replaceChatSessionInUrl,
|
||||
messageApi,
|
||||
});
|
||||
const openClearConversationDataModal = useCallback(() => {
|
||||
if (!activeConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingClearConversationSessionId(activeConversation.sessionId);
|
||||
}, [activeConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState !== 'connected') {
|
||||
@@ -4269,13 +4635,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
updatePendingMessageStatus(request.requestId, null, request.retryCount);
|
||||
return [];
|
||||
} catch {
|
||||
const nextRetryCount = request.retryCount + 1;
|
||||
|
||||
if (nextRetryCount >= CHAT_MAX_RETRY_ATTEMPTS) {
|
||||
updatePendingMessageStatus(request.requestId, 'failed', nextRetryCount);
|
||||
return [{ ...request, retryCount: nextRetryCount, failed: true }];
|
||||
}
|
||||
|
||||
const nextRetryCount = Math.min(request.retryCount + 1, CHAT_MAX_RETRY_ATTEMPTS);
|
||||
updatePendingMessageStatus(request.requestId, 'retrying', nextRetryCount);
|
||||
return [{ ...request, retryCount: nextRetryCount }];
|
||||
}
|
||||
@@ -4787,7 +5147,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
} = useConversationComposerController({
|
||||
activeSessionId,
|
||||
appConfigChat: appConfig.chat,
|
||||
draft,
|
||||
getDraft: () => draftRef.current,
|
||||
composerAttachments,
|
||||
isComposerAttachmentUploading,
|
||||
selectedChatType: effectiveChatType
|
||||
@@ -4802,7 +5162,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
messagesRef,
|
||||
pendingRequestsRef,
|
||||
shouldStickToBottomRef,
|
||||
setDraft,
|
||||
setDraft: setDraftValue,
|
||||
setComposerAttachments,
|
||||
setIsComposerAttachmentUploading,
|
||||
setMessages,
|
||||
@@ -4865,7 +5225,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
setPendingImportedDraftRequest(null);
|
||||
setDraft('');
|
||||
setDraftValue('');
|
||||
executeSendMessage({
|
||||
mode: pendingImportedDraftRequest.sendMode,
|
||||
text: pendingImportedDraftRequest.text,
|
||||
@@ -4886,7 +5246,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
pendingImportedDraftRequest,
|
||||
requestedSessionId,
|
||||
selectedChatType,
|
||||
setDraft,
|
||||
setDraftValue,
|
||||
setSelectedChatTypeId,
|
||||
]);
|
||||
|
||||
@@ -4905,7 +5265,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
description: resolveComposedChatTypeDescription(selectedChatType),
|
||||
}
|
||||
: (availableChatTypes[0] ?? null));
|
||||
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
|
||||
const trimmed = buildOutgoingMessageText(draftText ?? draftRef.current, composerAttachments).trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
@@ -4944,7 +5304,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
buildOutgoingMessageText,
|
||||
composerAttachments,
|
||||
createLocalMessage,
|
||||
draft,
|
||||
draftRef,
|
||||
effectiveChatType,
|
||||
executeSendMessage,
|
||||
isComposerAttachmentUploading,
|
||||
@@ -4981,6 +5341,44 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
[handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage],
|
||||
);
|
||||
|
||||
const handlePromptSubmit = useCallback(
|
||||
async ({ text, mode }: { text: string; mode: 'queue' | 'direct' }) => {
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!effectiveChatType) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 prompt 선택을 전송하지 못했습니다.'),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!activeSessionId.trim()) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('활성 대화방이 없어서 prompt 선택을 전송하지 못했습니다.'),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
executeSendMessage({
|
||||
mode,
|
||||
text: trimmed,
|
||||
chatTypeId: effectiveChatType.id,
|
||||
chatTypeLabel: effectiveChatType.name,
|
||||
chatTypeDescription: effectiveChatTypeDescription,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[activeSessionId, createLocalMessage, effectiveChatType, effectiveChatTypeDescription, executeSendMessage, setMessages],
|
||||
);
|
||||
|
||||
const handleCopyMessage = async (message: ChatMessage) => {
|
||||
await copyText(message.text);
|
||||
setCopiedMessageId(message.id);
|
||||
@@ -5187,6 +5585,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="채팅방 데이터 초기화">
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="채팅방 데이터 초기화"
|
||||
onClick={() => {
|
||||
setIsMobileActionGroupOpen(false);
|
||||
openClearConversationDataModal();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -5249,6 +5659,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="채팅방 데이터 초기화">
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="채팅방 데이터 초기화"
|
||||
onClick={openClearConversationDataModal}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={isMaximized ? '최대화 해제' : '최대화'}>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -5347,7 +5766,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
isSystemStatusPending={isSystemStatusPending}
|
||||
showScrollToBottom={showScrollToBottom}
|
||||
copiedMessageId={copiedMessageId}
|
||||
draft={draft}
|
||||
draft={draftSeed.value}
|
||||
draftVersion={draftSeed.version}
|
||||
composerAttachments={composerAttachments}
|
||||
isConversationLoading={isConversationContentLoading}
|
||||
conversationLoadingLabel={conversationLoadingLabel}
|
||||
@@ -5395,7 +5815,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
setIsSendWithoutContextEnabled((current) => !current);
|
||||
}}
|
||||
onClearDraft={() => {
|
||||
setDraft('');
|
||||
setDraftValue('');
|
||||
}}
|
||||
onToggleResourceStrip={() => {
|
||||
setIsResourceStripOpen((current) => !current);
|
||||
@@ -5431,6 +5851,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
void deleteStoredRequest(message.clientRequestId);
|
||||
}}
|
||||
onRemoveQueuedRequest={removeQueuedComposerRequest}
|
||||
onSubmitPrompt={handlePromptSubmit}
|
||||
/>
|
||||
) : (
|
||||
<div className="app-chat-panel__conversation-empty">
|
||||
@@ -5508,15 +5929,35 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
children: (
|
||||
<div className="app-chat-panel__context-drawer-section app-chat-panel__context-drawer-section--editor">
|
||||
<div className="app-chat-panel__context-drawer-section-head">
|
||||
<Text strong>현재 채팅유형</Text>
|
||||
<Text type="secondary">다른 유형을 고르는 대신, 이 방이 쓰는 기본 문맥 내용을 바로 확인하고 수정합니다.</Text>
|
||||
<Text strong>채팅기본유형</Text>
|
||||
<Text type="secondary">이 채팅방이 기본으로 사용할 유형을 선택하고, 그 유형의 기본 문맥도 함께 수정할 수 있습니다.</Text>
|
||||
</div>
|
||||
<Select
|
||||
value={editingRoomChatTypeId ?? undefined}
|
||||
placeholder="채팅유형을 선택하세요."
|
||||
options={availableChatTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
onChange={(nextChatTypeId) => {
|
||||
setEditingRoomChatTypeId(nextChatTypeId);
|
||||
const nextChatType =
|
||||
chatTypes.find((item) => item.id === nextChatTypeId) ??
|
||||
availableChatTypes.find((item) => item.id === nextChatTypeId) ??
|
||||
null;
|
||||
setEditingChatTypeDescription(nextChatType?.description ?? '');
|
||||
|
||||
if (!activeRoomContextSettings && !isEditingRoomDefaultContextsDirty) {
|
||||
setEditingRoomDefaultContextIds(resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatTypeId));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{contextDrawerChatType ? (
|
||||
<>
|
||||
<div className="app-chat-panel__context-drawer-card app-chat-panel__context-drawer-card--readonly">
|
||||
<span className="app-chat-panel__context-drawer-card-title">{contextDrawerChatType.name}</span>
|
||||
<Text type="secondary" className="app-chat-panel__context-drawer-card-copy">
|
||||
저장하면 이 채팅유형을 사용하는 다른 방의 기본 설명도 함께 갱신됩니다.
|
||||
아래 문맥 설명을 수정하면 이 채팅유형을 사용하는 다른 방의 기본 설명도 함께 갱신됩니다.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__context-drawer-textarea-shell">
|
||||
@@ -5579,6 +6020,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
className="app-chat-panel__context-drawer-checkbox"
|
||||
value={editingRoomDefaultContextIds}
|
||||
onChange={(checkedValues) => {
|
||||
setIsEditingRoomDefaultContextsDirty(true);
|
||||
setEditingRoomDefaultContextIds(
|
||||
checkedValues
|
||||
.map((value) => String(value).trim())
|
||||
@@ -5894,6 +6336,34 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(pendingClearConversation)}
|
||||
title="채팅방 데이터를 초기화할까요?"
|
||||
okText="초기화"
|
||||
cancelText="취소"
|
||||
okButtonProps={{ danger: true, autoFocus: true }}
|
||||
modalRender={renderModalWithEnterConfirm}
|
||||
zIndex={1700}
|
||||
onCancel={() => {
|
||||
setPendingClearConversationSessionId(null);
|
||||
}}
|
||||
onOk={async () => {
|
||||
const targetSessionId = pendingClearConversationSessionId;
|
||||
|
||||
if (!targetSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleClearConversation(targetSessionId);
|
||||
setPendingClearConversationSessionId(null);
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{pendingClearConversation?.title
|
||||
? `"${pendingClearConversation.title}" 채팅방의 이름과 설정은 유지되고, 메시지·요청·활동 로그만 초기화됩니다.`
|
||||
: '채팅방 이름과 설정은 유지하고, 메시지·요청·활동 로그만 초기화됩니다.'}
|
||||
</Text>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(pendingDeleteConversation)}
|
||||
title="대화방을 삭제할까요?"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { fetchPlanItems } from '../../features/planBoard/api';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters';
|
||||
import {
|
||||
cancelServerRestartReservation,
|
||||
@@ -819,6 +820,32 @@ function getServerRestartReservationOverlayState(
|
||||
}
|
||||
|
||||
if (reservation.status === 'executing') {
|
||||
if (reservation.target === 'work-server') {
|
||||
return {
|
||||
title: 'WORK 서버 재기동 중',
|
||||
statusText: '새 런타임 반영 중',
|
||||
detail: reservation.waitingReason?.trim() || 'WORK 서버를 재기동하고 새 런타임을 확인하는 중입니다.',
|
||||
steps: [
|
||||
{ label: '재기동 요청', status: 'done' },
|
||||
{ label: 'WORK 재기동', status: 'active' },
|
||||
{ label: '정상 기동 확인', status: 'pending' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (reservation.target === 'test') {
|
||||
return {
|
||||
title: 'TEST 서버 재기동 중',
|
||||
statusText: '새 런타임 반영 중',
|
||||
detail: reservation.waitingReason?.trim() || 'TEST 서버를 재기동하고 새 런타임을 확인하는 중입니다.',
|
||||
steps: [
|
||||
{ label: '재기동 요청', status: 'done' },
|
||||
{ label: 'TEST 재기동', status: 'active' },
|
||||
{ label: '정상 기동 확인', status: 'pending' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const startedTimestamp = reservation.startedAt ? Date.parse(reservation.startedAt) : Number.NaN;
|
||||
const workServerRestartAt = Number.isFinite(startedTimestamp)
|
||||
? startedTimestamp + RESERVED_RESTART_WORK_SERVER_DELAY_MS
|
||||
@@ -858,6 +885,33 @@ function getServerRestartReservationOverlayState(
|
||||
};
|
||||
}
|
||||
|
||||
if (reservation.status === 'recovering') {
|
||||
const targetLabel = reservation.autoFix.targetKey ? `${reservation.autoFix.targetKey.toUpperCase()} 자동 개선` : 'Codex 자동 개선';
|
||||
const statusText =
|
||||
reservation.autoFix.status === 'failed'
|
||||
? '자동 개선 실패'
|
||||
: reservation.autoFix.status === 'completed'
|
||||
? '자동 개선 완료'
|
||||
: '빌드 오류 분석 및 수정 중';
|
||||
const detail =
|
||||
reservation.autoFix.detail?.trim()
|
||||
|| reservation.autoFix.summary?.trim()
|
||||
|| reservation.waitingReason?.trim()
|
||||
|| '재기동을 막는 빌드 오류를 Codex가 자동으로 수정한 뒤 재기동을 다시 시도합니다.';
|
||||
|
||||
return {
|
||||
title: targetLabel,
|
||||
statusText,
|
||||
detail,
|
||||
steps: [
|
||||
{ label: reservation.target === 'all' ? '예약 확인' : '재기동 요청', status: 'done' },
|
||||
{ label: '빌드 실패 감지', status: 'done' },
|
||||
{ label: 'Codex 자동 개선', status: reservation.autoFix.status === 'completed' ? 'done' : 'active' },
|
||||
{ label: '재기동 재시도', status: reservation.autoFix.status === 'completed' ? 'active' : 'pending' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (reservation.status === 'completed' && reloadPending) {
|
||||
return {
|
||||
title: '재기동 완료 처리 중',
|
||||
@@ -954,7 +1008,7 @@ function hasServerRuntimeChanged(previous: ServerCommandItem | null, next: Serve
|
||||
|
||||
function hasServerUpdateStateImproved(previous: ServerCommandItem | null, next: ServerCommandItem | null) {
|
||||
if (!previous || !next) {
|
||||
return Boolean(next) && !next.buildRequired && !next.updateAvailable;
|
||||
return next ? !next.buildRequired && !next.updateAvailable : false;
|
||||
}
|
||||
|
||||
const previousNeedsUpdate = previous.buildRequired || previous.updateAvailable;
|
||||
@@ -1410,27 +1464,6 @@ function waitForDuration(durationMs: number) {
|
||||
});
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
export function MainHeader({
|
||||
activeTopMenu,
|
||||
sidebarCollapsed,
|
||||
@@ -1571,6 +1604,8 @@ export function MainHeader({
|
||||
? '대기 중'
|
||||
: serverRestartReservation.status === 'ready'
|
||||
? '자동 실행 대기'
|
||||
: serverRestartReservation.status === 'recovering'
|
||||
? 'Codex 자동 개선 중'
|
||||
: serverRestartReservation.status === 'executing'
|
||||
? '실행 중'
|
||||
: serverRestartReservation.status === 'completed'
|
||||
@@ -1599,6 +1634,10 @@ export function MainHeader({
|
||||
? serverRestartReservationPendingSummary
|
||||
: serverRestartReservation?.status === 'ready'
|
||||
? serverRestartReservationPendingSummary
|
||||
: serverRestartReservation?.status === 'recovering'
|
||||
? serverRestartReservation.autoFix.summary
|
||||
?? serverRestartReservation.waitingReason
|
||||
?? '빌드 오류 자동 개선을 진행 중입니다.'
|
||||
: serverRestartReservation?.status === 'executing'
|
||||
? serverRestartReservationTimingLabel
|
||||
: serverRestartReservation?.status === 'completed'
|
||||
@@ -3050,9 +3089,12 @@ export function MainHeader({
|
||||
aria-label="메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void copyText(feedback.message)
|
||||
void copyTextToClipboard(feedback.message)
|
||||
.then(() => {
|
||||
setCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
||||
setCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
@@ -3247,9 +3289,12 @@ export function MainHeader({
|
||||
aria-label="메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void copyText(appConfigFeedback.message)
|
||||
void copyTextToClipboard(appConfigFeedback.message)
|
||||
.then(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
||||
setAppConfigCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
@@ -4008,9 +4053,12 @@ export function MainHeader({
|
||||
aria-label="메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void copyText(appConfigFeedback.message)
|
||||
void copyTextToClipboard(appConfigFeedback.message)
|
||||
.then(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
||||
setAppConfigCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
@@ -4090,9 +4138,12 @@ export function MainHeader({
|
||||
aria-label="메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void copyText(appConfigFeedback.message)
|
||||
void copyTextToClipboard(appConfigFeedback.message)
|
||||
.then(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
|
||||
setAppConfigCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
|
||||
668
src/app/main/ManagementPage.shared.css
Executable file
668
src/app/main/ManagementPage.shared.css
Executable file
@@ -0,0 +1,668 @@
|
||||
.chat-type-management-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__card {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 4px 14px 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__list .ant-list {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-scroll > .ant-list + .ant-list,
|
||||
.chat-type-management-page__list-scroll > .ant-empty {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: auto;
|
||||
padding: 0 0 calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll:has(.chat-type-management-page__markdown-grid--maximized) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-form .ant-form-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header .ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item {
|
||||
cursor: pointer;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item--active {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description.ant-typography {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-field--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-options {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-option-copy {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-field {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__field-label {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions .ant-btn {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid--maximized {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: min(720px, calc(100dvh - 236px));
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane--desktop-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-input-textarea,
|
||||
.chat-type-management-page__markdown-pane .ant-input {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea {
|
||||
height: 100% !important;
|
||||
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: 100% !important;
|
||||
min-height: clamp(360px, calc(100dvh - 360px), 720px);
|
||||
overflow: auto !important;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item .ant-form-item-label {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item .ant-form-item-control-input {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--permissions .ant-checkbox-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--permissions .ant-checkbox-wrapper {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--name {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__field-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__card--pane-maximized .ant-card-body {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.chat-type-management-page,
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card,
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor,
|
||||
.chat-type-management-page__editor-form,
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page__markdown-preview {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head {
|
||||
min-height: 48px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra,
|
||||
.chat-type-management-page .ant-card-body {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card-head-title,
|
||||
.chat-type-management-page .ant-card-extra {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-scroll {
|
||||
gap: 3px;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__mobile-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-type-management-page__editor-toolbar {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-grid {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chat-type-management-page__default-context-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-textarea),
|
||||
.chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-preview) {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane--mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page__markdown-preview {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input,
|
||||
.chat-type-management-page__markdown-pane .ant-form-item-control-input-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page__markdown-textarea textarea,
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea {
|
||||
height: 100% !important;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-preview {
|
||||
min-height: clamp(220px, calc(100dvh - 560px), 320px);
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-preview {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input-content {
|
||||
flex: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
|
||||
.chat-type-management-page__markdown-textarea textarea {
|
||||
height: auto !important;
|
||||
min-height: clamp(320px, calc(100dvh - 430px), 520px) !important;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized {
|
||||
height: calc(100dvh - 52px);
|
||||
max-height: calc(100dvh - 52px);
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-head {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-head-title,
|
||||
.chat-type-management-page--pane-maximized .ant-card-extra {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .ant-card-body {
|
||||
padding: 4px 8px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__card,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-preview,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea,
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea textarea {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
|
||||
gap: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
|
||||
height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
min-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page--pane-maximized
|
||||
.chat-type-management-page__markdown-pane
|
||||
.ant-form-item-control-input-content {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__markdown-preview-body {
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__header-actions .ant-btn {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
.resource-management-page {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(320px, 1fr) minmax(360px, 1.08fr);
|
||||
grid-template-columns: minmax(220px, 0.82fr) minmax(280px, 1fr) minmax(300px, 1.08fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -17,6 +19,9 @@
|
||||
.resource-management-page__sidebar,
|
||||
.resource-management-page__content,
|
||||
.resource-management-page__preview-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 22px;
|
||||
overflow: clip;
|
||||
@@ -39,6 +44,19 @@
|
||||
.resource-management-page__content .ant-card-head,
|
||||
.resource-management-page__preview-card .ant-card-head {
|
||||
min-height: 58px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__sidebar .ant-card-head-wrapper,
|
||||
.resource-management-page__content .ant-card-head-wrapper,
|
||||
.resource-management-page__preview-card .ant-card-head-wrapper,
|
||||
.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,
|
||||
.resource-management-page__sidebar .ant-card-extra,
|
||||
.resource-management-page__content .ant-card-extra,
|
||||
.resource-management-page__preview-card .ant-card-extra {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__sidebar .ant-card-body,
|
||||
@@ -53,6 +71,29 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.resource-management-page__card-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__card-title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-management-page__card-title-subtitle {
|
||||
overflow: hidden;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-management-page__scope-copy {
|
||||
display: block;
|
||||
}
|
||||
@@ -62,6 +103,7 @@
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.resource-management-page__tree .ant-tree-node-content-wrapper {
|
||||
@@ -77,12 +119,18 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.resource-management-page__tree-title .ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.resource-management-page__content {
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -105,6 +153,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-main,
|
||||
@@ -115,6 +164,25 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-main {
|
||||
flex: 1 1 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-actions {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-path {
|
||||
min-width: min(100%, 320px);
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-path .ant-typography {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__guide {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -138,7 +206,7 @@
|
||||
.resource-management-page__list-header,
|
||||
.resource-management-page__list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 160px 88px 76px;
|
||||
grid-template-columns: minmax(0, 1.8fr) minmax(116px, 0.95fr) minmax(72px, 0.5fr) 72px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -155,6 +223,7 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.resource-management-page__list-row {
|
||||
@@ -184,13 +253,22 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-management-page__list-meta {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.resource-management-page__list-meta > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-management-page__entry-name-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -206,6 +284,96 @@
|
||||
padding-bottom: 17px;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-title-file {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta .ant-typography {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-bottom: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta .ant-typography-copy {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-nav {
|
||||
margin-bottom: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-nav-wrap,
|
||||
.resource-management-page__preview-tabs .ant-tabs-nav-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-content-holder,
|
||||
.resource-management-page__preview-tabs .ant-tabs-content,
|
||||
.resource-management-page__preview-tabs .ant-tabs-tabpane {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-content-holder {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-tabs .ant-tabs-tabpane-active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resource-management-page__tab-panel {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__editor-panel {
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.resource-management-page__editor.ant-input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100% !important;
|
||||
padding-bottom: max(16px, calc(env(safe-area-inset-bottom, 0px) + 10px));
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.resource-management-page__editor-actions {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-frame {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
@@ -217,12 +385,36 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.resource-management-page__html-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-management-page__html-preview > .resource-management-page__rich-preview {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__html-mode-switch {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.resource-management-page__html-mode-switch .ant-btn {
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.resource-management-page__text-preview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
@@ -236,6 +428,124 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: inset 0 0 0 1px #d9e1f2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview p,
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview li,
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview a,
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview code {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--markdown .markdown-preview code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--code {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--code .previewer-ui__editor,
|
||||
.resource-management-page__rich-preview--code .codex-diff-previewer {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--code .previewer-ui__editor-body,
|
||||
.resource-management-page__rich-preview--code .codex-diff-previewer__diff-body,
|
||||
.resource-management-page__rich-preview--code .codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-scroll {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid {
|
||||
width: 100%;
|
||||
min-width: max-content;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid th,
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid tbody tr:nth-child(even) td {
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
}
|
||||
|
||||
.resource-management-page__rich-preview--table .app-chat-panel__preview-table-grid tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__image-preview {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
@@ -447,13 +757,13 @@
|
||||
.resource-management-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.resource-management-page--mobile {
|
||||
min-height: 0;
|
||||
padding-inline: 1px;
|
||||
padding-bottom: max(10px, calc(env(safe-area-inset-bottom, 0px) + 6px));
|
||||
padding-inline: 0;
|
||||
padding-bottom: max(6px, calc(env(safe-area-inset-bottom, 0px) + 2px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -464,29 +774,85 @@
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
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);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-card {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-card > .ant-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
border-radius: 20px;
|
||||
box-shadow: inset 0 0 0 1px rgba(191, 204, 229, 0.9);
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 10px 8px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: #52607a;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button:disabled {
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button--active {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(186, 209, 255, 0.92),
|
||||
0 6px 18px rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.resource-management-page__mobile-nav-button-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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: 15px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.resource-management-page__list-header {
|
||||
@@ -527,6 +893,20 @@
|
||||
min-width: min(208px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.resource-management-page__tree {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__tree .ant-tree-treenode,
|
||||
.resource-management-page__tree .ant-tree-node-content-wrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__tree .ant-tree-switcher {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -536,14 +916,40 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-path {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__toolbar-actions .ant-btn {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-meta .ant-typography,
|
||||
.resource-management-page__preview-meta .ant-space-compact {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__preview-frame,
|
||||
.resource-management-page__image-preview,
|
||||
.resource-management-page__text-preview {
|
||||
min-height: 220px;
|
||||
.resource-management-page__text-preview,
|
||||
.resource-management-page__rich-preview {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-management-page__html-mode-switch {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-management-page__html-mode-switch .ant-btn {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.resource-management-page__editor {
|
||||
@@ -561,7 +967,7 @@
|
||||
|
||||
.resource-management-page__preview-card .ant-tabs-content-holder {
|
||||
overflow: hidden;
|
||||
padding-bottom: 1px;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 10px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -570,10 +976,26 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resource-management-page__editor-panel {
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 6px);
|
||||
}
|
||||
|
||||
.resource-management-page__editor-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-management-page--panel-preview .resource-management-page__preview-card .ant-card-body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-management-page--panel-tree .resource-management-page__sidebar,
|
||||
.resource-management-page--panel-list .resource-management-page__content,
|
||||
.resource-management-page--panel-preview .resource-management-page__preview-card {
|
||||
box-shadow: inset 0 0 0 1px rgba(191, 204, 229, 0.9);
|
||||
}
|
||||
|
||||
.resource-management-page__preview-modal-wrap--mobile .ant-modal {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
|
||||
@@ -26,10 +26,16 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import type { Key } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview/MarkdownPreviewContent';
|
||||
import { CodexDiffBlock } from '../../components/previewer';
|
||||
import { inferCodeLanguage, renderEditorBlock } from '../../components/previewer/renderers';
|
||||
import '../../components/previewer/PreviewerUI.css';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import {
|
||||
copyResourceManagerItem,
|
||||
createResourceManagerDirectory,
|
||||
@@ -47,6 +53,7 @@ import {
|
||||
type ResourceManagerTreeNode,
|
||||
type ResourceManagerTreeRoot,
|
||||
} from './resourceManagerApi';
|
||||
import { ChatDataTablePreview, resolveTabularPreviewModel } from './mainChatPanel/ChatDataTablePreview';
|
||||
import './ResourceManagementPage.css';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
@@ -81,6 +88,8 @@ type PreviewOffset = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
type HtmlPreviewMode = 'browser' | 'source';
|
||||
|
||||
type CreateEntryModalState =
|
||||
| {
|
||||
type: 'file' | 'folder';
|
||||
@@ -150,6 +159,10 @@ function isPdfFile(file: ResourceManagerFileDetail | null) {
|
||||
return file?.mimeType === 'application/pdf';
|
||||
}
|
||||
|
||||
function normalizeFileExtension(file: ResourceManagerFileDetail | null) {
|
||||
return file?.extension?.replace(/^\./, '').trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
function isHtmlFile(file: ResourceManagerFileDetail | null) {
|
||||
if (!file) {
|
||||
return false;
|
||||
@@ -158,12 +171,121 @@ function isHtmlFile(file: ResourceManagerFileDetail | null) {
|
||||
return file.mimeType.includes('html') || file.extension === '.html' || file.extension === '.htm';
|
||||
}
|
||||
|
||||
function isTextPreviewFile(file: ResourceManagerFileDetail | null) {
|
||||
function buildHtmlPreviewDocument(file: ResourceManagerFileDetail) {
|
||||
const content = file.content ?? '';
|
||||
|
||||
if (!content.trim()) {
|
||||
return '<!doctype html><html><body></body></html>';
|
||||
}
|
||||
|
||||
const baseHref = file.previewUrl.replace(/"/g, '"');
|
||||
const baseTag = `<base href="${baseHref}">`;
|
||||
|
||||
if (/<head[\s>]/i.test(content)) {
|
||||
return content.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${baseTag}`);
|
||||
}
|
||||
|
||||
if (/<html[\s>]/i.test(content)) {
|
||||
return content.replace(/<html(\s[^>]*)?>/i, (match) => `${match}<head>${baseTag}</head>`);
|
||||
}
|
||||
|
||||
return `<!doctype html><html><head>${baseTag}</head><body>${content}</body></html>`;
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: ResourceManagerFileDetail | null) {
|
||||
const extension = normalizeFileExtension(file);
|
||||
return extension === 'md' || extension === 'markdown';
|
||||
}
|
||||
|
||||
function isDiffFile(file: ResourceManagerFileDetail | null) {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return file.isTextEditable && !isHtmlFile(file);
|
||||
const extension = normalizeFileExtension(file);
|
||||
if (extension === 'diff' || extension === 'patch') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const content = file.content ?? '';
|
||||
return /^(diff --git|@@\s|---\s|\+\+\+\s)/m.test(content);
|
||||
}
|
||||
|
||||
function resolveCodeLanguageForResource(file: ResourceManagerFileDetail) {
|
||||
const extension = normalizeFileExtension(file);
|
||||
|
||||
if (extension === 'tsx' || extension === 'ts') {
|
||||
return 'typescript';
|
||||
}
|
||||
|
||||
if (extension === 'jsx' || extension === 'js' || extension === 'mjs' || extension === 'cjs') {
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
if (extension === 'json') {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (extension === 'css') {
|
||||
return 'css';
|
||||
}
|
||||
|
||||
if (extension === 'scss') {
|
||||
return 'scss';
|
||||
}
|
||||
|
||||
if (extension === 'html' || extension === 'htm') {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
if (extension === 'java') {
|
||||
return 'java';
|
||||
}
|
||||
|
||||
if (extension === 'kt') {
|
||||
return 'kotlin';
|
||||
}
|
||||
|
||||
if (extension === 'py') {
|
||||
return 'python';
|
||||
}
|
||||
|
||||
if (extension === 'go') {
|
||||
return 'go';
|
||||
}
|
||||
|
||||
if (extension === 'rs') {
|
||||
return 'rust';
|
||||
}
|
||||
|
||||
if (extension === 'sql') {
|
||||
return 'sql';
|
||||
}
|
||||
|
||||
if (
|
||||
extension === 'sh' ||
|
||||
extension === 'bash' ||
|
||||
extension === 'zsh' ||
|
||||
extension === 'env' ||
|
||||
extension === 'ini' ||
|
||||
extension === 'conf'
|
||||
) {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
if (extension === 'yml' || extension === 'yaml') {
|
||||
return 'yaml';
|
||||
}
|
||||
|
||||
if (extension === 'xml') {
|
||||
return 'xml';
|
||||
}
|
||||
|
||||
if (extension === 'svg') {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
return inferCodeLanguage(extension || 'text');
|
||||
}
|
||||
|
||||
function isNativeContextMenuBypassTarget(target: EventTarget | null) {
|
||||
@@ -317,6 +439,15 @@ function findTreeNode(treeRoot: ResourceManagerTreeRoot | null, targetPath: stri
|
||||
return visit(treeRoot.tree);
|
||||
}
|
||||
|
||||
function renderCardTitle(title: string, subtitle?: ReactNode) {
|
||||
return (
|
||||
<div className="resource-management-page__card-title">
|
||||
<span className="resource-management-page__card-title-text">{title}</span>
|
||||
{subtitle ? <span className="resource-management-page__card-title-subtitle">{subtitle}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceManagementPage() {
|
||||
const { message } = App.useApp();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -335,6 +466,7 @@ export function ResourceManagementPage() {
|
||||
const [selectedFile, setSelectedFile] = useState<ResourceManagerFileDetail | null>(null);
|
||||
const [editorContent, setEditorContent] = useState('');
|
||||
const [activePreviewTab, setActivePreviewTab] = useState('preview');
|
||||
const [htmlPreviewMode, setHtmlPreviewMode] = useState<HtmlPreviewMode>('browser');
|
||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [mobilePanel, setMobilePanel] = useState<MobilePanel>('list');
|
||||
@@ -470,6 +602,7 @@ export function ResourceManagementPage() {
|
||||
};
|
||||
|
||||
const directoryTargets = useMemo(() => collectDirectoryTargets(treeRoot), [treeRoot]);
|
||||
const currentDirectoryLabel = formatPathLabel(selectedDirectoryPath);
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -518,6 +651,7 @@ export function ResourceManagementPage() {
|
||||
setPreviewZoom(1);
|
||||
setPreviewOffset({ x: 0, y: 0 });
|
||||
previewTouchGestureRef.current = null;
|
||||
setHtmlPreviewMode('browser');
|
||||
}, [selectedFile?.path, isPreviewMaximized]);
|
||||
|
||||
const cancelLongPress = () => {
|
||||
@@ -1006,7 +1140,7 @@ export function ResourceManagementPage() {
|
||||
};
|
||||
|
||||
const handleContextMenuAction = async (
|
||||
action: 'open' | 'copy' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||
action: 'open' | 'copy' | 'copy-path' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||
) => {
|
||||
const target = contextMenu.target;
|
||||
setContextMenu((current) => ({ ...current, open: false, target: null }));
|
||||
@@ -1049,6 +1183,16 @@ export function ResourceManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'copy-path') {
|
||||
try {
|
||||
await copyTextToClipboard(formatPathLabel(target.entry.path));
|
||||
message.success('경로를 복사했습니다.');
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '경로 복사에 실패했습니다.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'move') {
|
||||
openCopyMoveModal('move', target.entry);
|
||||
return;
|
||||
@@ -1065,7 +1209,7 @@ export function ResourceManagementPage() {
|
||||
};
|
||||
|
||||
const bindContextMenuAction = (
|
||||
action: 'open' | 'copy' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||
action: 'open' | 'copy' | 'copy-path' | 'move' | 'rename' | 'delete' | 'new-folder' | 'new-file' | 'paste',
|
||||
) => ({
|
||||
onPointerDown: (event: ReactMouseEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -1074,13 +1218,17 @@ export function ResourceManagementPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const canZoomPreview = activePreviewTab === 'preview' && (isImageFile(selectedFile) || isHtmlFile(selectedFile));
|
||||
const canZoomPreview =
|
||||
activePreviewTab === 'preview' &&
|
||||
(isImageFile(selectedFile) || (isHtmlFile(selectedFile) && htmlPreviewMode === 'browser'));
|
||||
|
||||
const renderPreviewContent = (maximized = false) => {
|
||||
if (!selectedFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileContent = selectedFile.content ?? '';
|
||||
|
||||
if (isImageFile(selectedFile)) {
|
||||
if (!maximized) {
|
||||
return <img alt={selectedFile.name} src={selectedFile.previewUrl} className="resource-management-page__image-preview" />;
|
||||
@@ -1109,12 +1257,17 @@ export function ResourceManagementPage() {
|
||||
return <iframe title={selectedFile.name} src={selectedFile.previewUrl} className="resource-management-page__preview-frame" />;
|
||||
}
|
||||
|
||||
if (isTextPreviewFile(selectedFile)) {
|
||||
return <pre className="resource-management-page__text-preview">{selectedFile.content ?? ''}</pre>;
|
||||
if (isMarkdownFile(selectedFile)) {
|
||||
return (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--markdown">
|
||||
<MarkdownPreviewContent content={fileContent || '# Preview\n\n표시할 markdown 본문이 없습니다.'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHtmlFile(selectedFile) && maximized) {
|
||||
return (
|
||||
if (isHtmlFile(selectedFile)) {
|
||||
const previewDocument = buildHtmlPreviewDocument(selectedFile);
|
||||
const browserPreview = maximized ? (
|
||||
<div
|
||||
ref={previewShellRef}
|
||||
className="resource-management-page__zoom-shell resource-management-page__zoom-shell--frame resource-management-page__zoom-shell--touch-zoom"
|
||||
@@ -1129,12 +1282,61 @@ export function ResourceManagementPage() {
|
||||
>
|
||||
<iframe
|
||||
title={selectedFile.name}
|
||||
src={selectedFile.previewUrl}
|
||||
srcDoc={previewDocument}
|
||||
className="resource-management-page__preview-frame resource-management-page__preview-frame--zoomable"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<iframe title={selectedFile.name} srcDoc={previewDocument} className="resource-management-page__preview-frame" />
|
||||
);
|
||||
|
||||
const sourcePreview = (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--code">
|
||||
{renderEditorBlock(fileContent || '표시할 preview 본문이 없습니다.', resolveCodeLanguageForResource(selectedFile), 'code')}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <div className="resource-management-page__html-preview">{htmlPreviewMode === 'browser' ? browserPreview : sourcePreview}</div>;
|
||||
}
|
||||
|
||||
if (selectedFile.isTextEditable) {
|
||||
const tabularModel = resolveTabularPreviewModel(
|
||||
{
|
||||
label: selectedFile.name,
|
||||
url: selectedFile.previewUrl,
|
||||
kind: 'document',
|
||||
},
|
||||
fileContent,
|
||||
);
|
||||
|
||||
if (tabularModel) {
|
||||
return (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--table">
|
||||
<ChatDataTablePreview model={tabularModel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDiffFile(selectedFile)) {
|
||||
return (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--code">
|
||||
<CodexDiffBlock
|
||||
diffText={fileContent}
|
||||
summary={`${selectedFile.name} 기준 raw diff preview입니다.`}
|
||||
showToolbar={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedFile.isTextEditable) {
|
||||
return (
|
||||
<div className="resource-management-page__rich-preview resource-management-page__rich-preview--code">
|
||||
{renderEditorBlock(fileContent || '표시할 preview 본문이 없습니다.', resolveCodeLanguageForResource(selectedFile), 'code')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1151,7 +1353,7 @@ export function ResourceManagementPage() {
|
||||
<span>미리보기</span>
|
||||
</Space>
|
||||
),
|
||||
children: renderPreviewContent(),
|
||||
children: <div className="resource-management-page__tab-panel">{renderPreviewContent()}</div>,
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
@@ -1162,22 +1364,21 @@ export function ResourceManagementPage() {
|
||||
</Space>
|
||||
),
|
||||
children: selectedFile.isTextEditable ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div className="resource-management-page__tab-panel resource-management-page__editor-panel">
|
||||
<TextArea
|
||||
value={editorContent}
|
||||
onChange={(event) => {
|
||||
setEditorContent(event.target.value);
|
||||
}}
|
||||
className="resource-management-page__editor"
|
||||
autoSize={{ minRows: 14, maxRows: 22 }}
|
||||
/>
|
||||
<Space>
|
||||
<Space className="resource-management-page__editor-actions">
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSaveFile()}>
|
||||
저장
|
||||
</Button>
|
||||
<Text type="secondary">{selectedFile.mimeType}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Alert showIcon type="warning" message="이 파일은 텍스트 편집을 지원하지 않습니다." />
|
||||
),
|
||||
@@ -1276,7 +1477,7 @@ export function ResourceManagementPage() {
|
||||
}, [treeRoot]);
|
||||
|
||||
const renderTreeCard = () => (
|
||||
<Card title="리소스 트리" className="resource-management-page__sidebar">
|
||||
<Card title={renderCardTitle('리소스 트리', 'resource/ 루트')} className="resource-management-page__sidebar">
|
||||
<Text type="secondary" className="resource-management-page__scope-copy">
|
||||
서버별 분리 없이 같은 `resource/` 루트를 트리와 탐색기에서 함께 봅니다.
|
||||
</Text>
|
||||
@@ -1300,7 +1501,7 @@ export function ResourceManagementPage() {
|
||||
|
||||
const renderListCard = () => (
|
||||
<Card
|
||||
title="폴더 목록"
|
||||
title={renderCardTitle('폴더 목록', `${directoryItems.length}개 항목`)}
|
||||
className="resource-management-page__content"
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void refreshAll(selectedDirectoryPath, false)}>
|
||||
@@ -1311,7 +1512,11 @@ export function ResourceManagementPage() {
|
||||
<div className="resource-management-page__workspace">
|
||||
<div className="resource-management-page__toolbar">
|
||||
<div className="resource-management-page__toolbar-main">
|
||||
<Text strong>{formatPathLabel(selectedDirectoryPath)}</Text>
|
||||
<div className="resource-management-page__toolbar-path">
|
||||
<Text strong ellipsis={{ tooltip: currentDirectoryLabel }}>
|
||||
{currentDirectoryLabel}
|
||||
</Text>
|
||||
</div>
|
||||
{selectedDirectoryPath ? (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -1481,7 +1686,18 @@ export function ResourceManagementPage() {
|
||||
|
||||
const renderPreviewCard = () => (
|
||||
<Card
|
||||
title={selectedFile ? `미리보기 / 편집 - ${selectedFile.name}` : '미리보기 / 편집'}
|
||||
title={
|
||||
renderCardTitle(
|
||||
'미리보기 / 편집',
|
||||
selectedFile ? (
|
||||
<span className="resource-management-page__preview-title-file" title={selectedFile.name}>
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
) : (
|
||||
'선택한 파일 없음'
|
||||
),
|
||||
)
|
||||
}
|
||||
className="resource-management-page__preview-card"
|
||||
extra={
|
||||
selectedFile && activePreviewTab === 'preview' ? (
|
||||
@@ -1504,11 +1720,36 @@ export function ResourceManagementPage() {
|
||||
</div>
|
||||
) : selectedFile ? (
|
||||
<>
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text type="secondary">{formatPathLabel(selectedFile.path)}</Text>
|
||||
<Space size={[8, 8]} wrap className="resource-management-page__preview-meta">
|
||||
<Text type="secondary" ellipsis={{ tooltip: formatPathLabel(selectedFile.path) }}>
|
||||
{formatPathLabel(selectedFile.path)}
|
||||
</Text>
|
||||
<Text copyable={{ text: selectedFile.previewUrl }}>미리보기 URL 복사</Text>
|
||||
{isHtmlFile(selectedFile) ? (
|
||||
<Space.Compact className="resource-management-page__html-mode-switch">
|
||||
<Button
|
||||
size="small"
|
||||
type={htmlPreviewMode === 'browser' ? 'primary' : 'default'}
|
||||
onClick={() => setHtmlPreviewMode('browser')}
|
||||
>
|
||||
브라우저
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type={htmlPreviewMode === 'source' ? 'primary' : 'default'}
|
||||
onClick={() => setHtmlPreviewMode('source')}
|
||||
>
|
||||
소스
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
) : null}
|
||||
</Space>
|
||||
<Tabs activeKey={activePreviewTab} onChange={setActivePreviewTab} items={previewTabItems} />
|
||||
<Tabs
|
||||
className="resource-management-page__preview-tabs"
|
||||
activeKey={activePreviewTab}
|
||||
onChange={setActivePreviewTab}
|
||||
items={previewTabItems}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Empty
|
||||
@@ -1519,6 +1760,35 @@ export function ResourceManagementPage() {
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderMobileNavButton = (panel: MobilePanel, label: string, icon: ReactNode, disabled = false) => {
|
||||
const isActive = mobilePanel === panel;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={panel}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'resource-management-page__mobile-nav-button',
|
||||
isActive ? 'resource-management-page__mobile-nav-button--active' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setMobilePanel(panel);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="resource-management-page__mobile-nav-button-icon">{icon}</span>
|
||||
<span className="resource-management-page__mobile-nav-button-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -1535,7 +1805,7 @@ export function ResourceManagementPage() {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onSelectStartCapture={preventSelection}
|
||||
onMouseDownCapture={preventSelection}
|
||||
onDragStartCapture={preventSelection}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (event.pointerType === 'touch' && !isNativeContextMenuBypassTarget(event.target)) {
|
||||
@@ -1545,20 +1815,10 @@ export function ResourceManagementPage() {
|
||||
>
|
||||
{isMobileViewport ? (
|
||||
<>
|
||||
<div className="resource-management-page__mobile-nav">
|
||||
<Button type={mobilePanel === 'tree' ? 'primary' : 'default'} onClick={() => setMobilePanel('tree')}>
|
||||
트리
|
||||
</Button>
|
||||
<Button type={mobilePanel === 'list' ? 'primary' : 'default'} onClick={() => setMobilePanel('list')}>
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
type={mobilePanel === 'preview' ? 'primary' : 'default'}
|
||||
disabled={!selectedFile}
|
||||
onClick={() => setMobilePanel('preview')}
|
||||
>
|
||||
미리보기
|
||||
</Button>
|
||||
<div className="resource-management-page__mobile-nav" role="tablist" aria-label="리소스 보기 전환">
|
||||
{renderMobileNavButton('tree', '트리', <FolderOpenOutlined />)}
|
||||
{renderMobileNavButton('list', '목록', <FileTextOutlined />)}
|
||||
{renderMobileNavButton('preview', '미리보기', <EyeOutlined />, !selectedFile)}
|
||||
</div>
|
||||
<div className="resource-management-page__mobile-card">
|
||||
{mobilePanel === 'tree' ? renderTreeCard() : null}
|
||||
@@ -1600,6 +1860,12 @@ export function ResourceManagementPage() {
|
||||
<CopyOutlined />
|
||||
<span>복사</span>
|
||||
</button>
|
||||
{contextMenu.target.entry.type === 'directory' ? (
|
||||
<button type="button" {...bindContextMenuAction('copy-path')}>
|
||||
<CopyOutlined />
|
||||
<span>경로 복사</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" {...bindContextMenuAction('move')}>
|
||||
<ScissorOutlined />
|
||||
<span>이동</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
|
||||
export type ChatDefaultContextRecord = {
|
||||
@@ -29,30 +29,12 @@ type ChatContextSettingsStore = {
|
||||
roomContexts: ChatRoomContextSettings[];
|
||||
};
|
||||
|
||||
const CHAT_CONTEXT_SETTINGS_STORAGE_KEY = 'work-app:chat-context-settings';
|
||||
export type ChatContextSettingsStoreSource = 'server' | 'optimistic';
|
||||
|
||||
const CHAT_CONTEXT_SETTINGS_SYNC_EVENT = 'work-app:chat-context-settings-changed';
|
||||
const CHAT_CONTEXT_SETTINGS_API_PATH = '/chat-context-settings';
|
||||
const CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
const DEFAULT_CHAT_DEFAULT_CONTEXTS: ChatDefaultContextRecord[] = [
|
||||
{
|
||||
id: 'chat-default-mobile-verification',
|
||||
title: '모바일 검증',
|
||||
content:
|
||||
'## 검증\n- UI 변경은 모바일 브라우저 환경에서 먼저 확인합니다.\n- 토큰 등록이 필요한 화면은 등록 토큰이 주입된 상태에서 검증합니다.',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'chat-default-resource-output',
|
||||
title: '리소스 출력',
|
||||
content:
|
||||
'## 산출물\n- 문서, 이미지, 코드 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 경로 기준으로 제공합니다.\n- 최종 검증 이미지는 `[[preview:URL]]` 형식으로 남깁니다.',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
@@ -94,7 +76,7 @@ function normalizeDefaultContextIds(defaultContextIds: string[] | null | undefin
|
||||
function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, ChatDefaultContextRecord>();
|
||||
|
||||
[...(items ?? []), ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
|
||||
(items ?? [])
|
||||
.map((item) => normalizeDefaultContext(item))
|
||||
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
@@ -176,23 +158,7 @@ function sanitizeStore(input: Partial<ChatContextSettingsStore> | null | undefin
|
||||
};
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
if (typeof window === 'undefined') {
|
||||
return sanitizeStore(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CHAT_CONTEXT_SETTINGS_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return sanitizeStore(null);
|
||||
}
|
||||
|
||||
return sanitizeStore(JSON.parse(raw) as Partial<ChatContextSettingsStore>);
|
||||
} catch {
|
||||
return sanitizeStore(null);
|
||||
}
|
||||
}
|
||||
const EMPTY_CHAT_CONTEXT_SETTINGS_STORE = sanitizeStore(null);
|
||||
|
||||
function emitStoreChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -202,17 +168,6 @@ function emitStoreChange() {
|
||||
window.dispatchEvent(new Event(CHAT_CONTEXT_SETTINGS_SYNC_EVENT));
|
||||
}
|
||||
|
||||
function saveStore(store: ChatContextSettingsStore) {
|
||||
if (typeof window === 'undefined') {
|
||||
return store;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeStore(store);
|
||||
window.localStorage.setItem(CHAT_CONTEXT_SETTINGS_STORAGE_KEY, JSON.stringify(sanitized));
|
||||
emitStoreChange();
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function resolveChatContextSettingsApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
@@ -247,19 +202,33 @@ const CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL = resolveChatContextSettingsFallba
|
||||
async function requestChatContextSettingsOnce<T>(baseUrl: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const normalizedMethod = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS);
|
||||
|
||||
// 기본 유형 설정은 origin별 분기 없이 전역 설정을 source of truth로 사용한다.
|
||||
headers.delete('X-App-Origin');
|
||||
headers.delete('X-App-Domain');
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
headers.set('Cache-Control', 'no-cache, no-store, max-age=0');
|
||||
headers.set('Pragma', 'no-cache');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}`, {
|
||||
const requestUrl =
|
||||
normalizedMethod === 'GET'
|
||||
? `${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}?__ts=${Date.now()}`
|
||||
: `${baseUrl}${CHAT_CONTEXT_SETTINGS_API_PATH}`;
|
||||
const response = await fetch(requestUrl, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
|
||||
cache: init?.cache ?? (normalizedMethod === 'GET' ? 'reload' : undefined),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -315,6 +284,132 @@ async function saveStoreToServer(store: ChatContextSettingsStore) {
|
||||
return sanitizeStore(response.settings ?? sanitized);
|
||||
}
|
||||
|
||||
type ChatContextSettingsRegistrySnapshot = ChatContextSettingsStore & {
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
lastLoadedAt: string | null;
|
||||
lastFailedAt: string | null;
|
||||
hasLoadedFromServer: boolean;
|
||||
storeSource: ChatContextSettingsStoreSource;
|
||||
};
|
||||
|
||||
const EMPTY_CHAT_CONTEXT_SETTINGS_REGISTRY_SNAPSHOT: ChatContextSettingsRegistrySnapshot = {
|
||||
...EMPTY_CHAT_CONTEXT_SETTINGS_STORE,
|
||||
isLoading: false,
|
||||
errorMessage: '',
|
||||
lastLoadedAt: null,
|
||||
lastFailedAt: null,
|
||||
hasLoadedFromServer: false,
|
||||
storeSource: 'server',
|
||||
};
|
||||
|
||||
let chatContextSettingsRegistrySnapshot = EMPTY_CHAT_CONTEXT_SETTINGS_REGISTRY_SNAPSHOT;
|
||||
let chatContextSettingsRegistryRequestSequence = 0;
|
||||
let chatContextSettingsRegistryWindowEventsBound = false;
|
||||
const chatContextSettingsRegistryListeners = new Set<() => void>();
|
||||
|
||||
function emitChatContextSettingsRegistryChange() {
|
||||
chatContextSettingsRegistryListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
|
||||
function getChatContextSettingsRegistrySnapshot() {
|
||||
return chatContextSettingsRegistrySnapshot;
|
||||
}
|
||||
|
||||
function setChatContextSettingsRegistrySnapshot(
|
||||
updater:
|
||||
| ChatContextSettingsRegistrySnapshot
|
||||
| ((current: ChatContextSettingsRegistrySnapshot) => ChatContextSettingsRegistrySnapshot),
|
||||
) {
|
||||
const nextSnapshot = typeof updater === 'function' ? updater(chatContextSettingsRegistrySnapshot) : updater;
|
||||
|
||||
chatContextSettingsRegistrySnapshot = nextSnapshot;
|
||||
emitChatContextSettingsRegistryChange();
|
||||
}
|
||||
|
||||
function subscribeChatContextSettingsRegistry(listener: () => void) {
|
||||
chatContextSettingsRegistryListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
chatContextSettingsRegistryListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async function reloadChatContextSettingsRegistryFromServer() {
|
||||
const requestSequence = chatContextSettingsRegistryRequestSequence + 1;
|
||||
chatContextSettingsRegistryRequestSequence = requestSequence;
|
||||
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const serverStore = await fetchStoreFromServer();
|
||||
const saved = sanitizeStore(serverStore);
|
||||
|
||||
if (chatContextSettingsRegistryRequestSequence === requestSequence) {
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
...saved,
|
||||
isLoading: false,
|
||||
errorMessage: '',
|
||||
lastLoadedAt: new Date().toISOString(),
|
||||
lastFailedAt: null,
|
||||
hasLoadedFromServer: true,
|
||||
storeSource: 'server',
|
||||
}));
|
||||
}
|
||||
|
||||
return saved;
|
||||
} catch (error) {
|
||||
if (chatContextSettingsRegistryRequestSequence === requestSequence) {
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...(current.lastLoadedAt ? current : EMPTY_CHAT_CONTEXT_SETTINGS_REGISTRY_SNAPSHOT),
|
||||
isLoading: false,
|
||||
errorMessage: error instanceof Error ? error.message : '채팅 Context 설정을 불러오지 못했습니다.',
|
||||
lastFailedAt: new Date().toISOString(),
|
||||
hasLoadedFromServer: current.lastLoadedAt !== null,
|
||||
storeSource: current.lastLoadedAt ? current.storeSource : 'server',
|
||||
}));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureChatContextSettingsRegistryWindowEvents() {
|
||||
if (chatContextSettingsRegistryWindowEventsBound || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSync = () => {
|
||||
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||
};
|
||||
const handleWindowFocus = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
|
||||
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
window.addEventListener('pageshow', handleWindowFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
chatContextSettingsRegistryWindowEventsBound = true;
|
||||
}
|
||||
|
||||
export function resolveChatTypeDefaultContextIds(
|
||||
selections: ChatTypeDefaultContextSelection[],
|
||||
chatTypeId: string | null | undefined,
|
||||
@@ -452,70 +547,71 @@ export function pruneChatRoomContextSettings(roomContexts: ChatRoomContextSettin
|
||||
}
|
||||
|
||||
export function useChatContextSettingsRegistry() {
|
||||
const [store, setStoreState] = useState<ChatContextSettingsStore>(() => loadStore());
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const isMountedRef = useRef(true);
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribeChatContextSettingsRegistry,
|
||||
getChatContextSettingsRegistrySnapshot,
|
||||
getChatContextSettingsRegistrySnapshot,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
const syncStore = async () => {
|
||||
try {
|
||||
const serverStore = await fetchStoreFromServer();
|
||||
const saved = saveStore(serverStore);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setStoreState(saved);
|
||||
setErrorMessage('');
|
||||
}
|
||||
} catch (error) {
|
||||
const localStore = loadStore();
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setStoreState(localStore);
|
||||
setErrorMessage(error instanceof Error ? error.message : '채팅 Context 설정을 불러오지 못했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void syncStore();
|
||||
const handleSync = () => {
|
||||
void syncStore();
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
|
||||
window.addEventListener('storage', syncStore);
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
window.removeEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
|
||||
window.removeEventListener('storage', syncStore);
|
||||
};
|
||||
ensureChatContextSettingsRegistryWindowEvents();
|
||||
void reloadChatContextSettingsRegistryFromServer().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...store,
|
||||
errorMessage,
|
||||
...snapshot,
|
||||
reload: reloadChatContextSettingsRegistryFromServer,
|
||||
setStore: (
|
||||
updater:
|
||||
| ChatContextSettingsStore
|
||||
| ((current: ChatContextSettingsStore) => ChatContextSettingsStore),
|
||||
) => {
|
||||
const nextStore = typeof updater === 'function' ? updater(loadStore()) : updater;
|
||||
const saved = saveStore(nextStore);
|
||||
const currentStore: ChatContextSettingsStore = {
|
||||
defaultContexts: snapshot.defaultContexts,
|
||||
chatTypeDefaults: snapshot.chatTypeDefaults,
|
||||
roomContexts: snapshot.roomContexts,
|
||||
};
|
||||
const nextStore = typeof updater === 'function' ? updater(currentStore) : updater;
|
||||
const saved = sanitizeStore(nextStore);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setStoreState(saved);
|
||||
setErrorMessage('');
|
||||
}
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
...saved,
|
||||
errorMessage: '',
|
||||
lastFailedAt: null,
|
||||
storeSource: 'optimistic',
|
||||
}));
|
||||
|
||||
return saveStoreToServer(saved).then((serverSaved) => {
|
||||
if (isMountedRef.current) {
|
||||
setStoreState(serverSaved);
|
||||
}
|
||||
return saveStoreToServer(saved)
|
||||
.then((serverSaved) => {
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
...serverSaved,
|
||||
errorMessage: '',
|
||||
lastLoadedAt: new Date().toISOString(),
|
||||
lastFailedAt: null,
|
||||
hasLoadedFromServer: true,
|
||||
storeSource: 'server',
|
||||
}));
|
||||
|
||||
return serverSaved;
|
||||
});
|
||||
emitStoreChange();
|
||||
return serverSaved;
|
||||
})
|
||||
.catch(async (error) => {
|
||||
setChatContextSettingsRegistrySnapshot((current) => ({
|
||||
...current,
|
||||
errorMessage: error instanceof Error ? error.message : '채팅 Context 설정 저장에 실패했습니다.',
|
||||
lastFailedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
try {
|
||||
await reloadChatContextSettingsRegistryFromServer();
|
||||
} catch {
|
||||
// 저장 실패 직후에도 서버 원본 재조회는 best-effort로 유지한다.
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ export type ChatTypeRecord = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ChatTypeRegistrySnapshot = {
|
||||
builtInChatTypes: ChatTypeRecord[];
|
||||
customChatTypes: ChatTypeRecord[];
|
||||
chatTypes: ChatTypeRecord[];
|
||||
};
|
||||
|
||||
export type ChatTypeInput = {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -114,6 +120,11 @@ function mergeWithDefaultChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null |
|
||||
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;
|
||||
@@ -200,26 +211,58 @@ async function requestChatTypes<T>(init?: RequestInit) {
|
||||
}
|
||||
|
||||
async function fetchChatTypesFromServer() {
|
||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({
|
||||
const response = await requestChatTypes<{
|
||||
ok: boolean;
|
||||
builtInChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||
customChatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||
chatTypes?: Partial<ChatTypeRecord>[] | null;
|
||||
}>({
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.chatTypes == null) {
|
||||
return mergeWithDefaultChatTypes(DEFAULT_CHAT_TYPES);
|
||||
}
|
||||
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 mergeWithDefaultChatTypes(response.chatTypes);
|
||||
return {
|
||||
builtInChatTypes,
|
||||
customChatTypes,
|
||||
chatTypes,
|
||||
} satisfies ChatTypeRegistrySnapshot;
|
||||
}
|
||||
|
||||
async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
|
||||
const resolved = mergeWithDefaultChatTypes(chatTypes);
|
||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<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({ chatTypes: resolved }),
|
||||
body: JSON.stringify({ customChatTypes }),
|
||||
});
|
||||
|
||||
emitChatTypesChange();
|
||||
return mergeWithDefaultChatTypes(response.chatTypes);
|
||||
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,
|
||||
} satisfies ChatTypeRegistrySnapshot;
|
||||
}
|
||||
|
||||
export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) {
|
||||
@@ -264,9 +307,27 @@ 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 [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,
|
||||
}));
|
||||
|
||||
const applySnapshot = (snapshot: ChatTypeRegistrySnapshot) => {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBuiltInChatTypesState(snapshot.builtInChatTypes);
|
||||
setCustomChatTypesState(snapshot.customChatTypes);
|
||||
setChatTypesState(snapshot.chatTypes);
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
@@ -274,26 +335,39 @@ export function useChatTypeRegistry() {
|
||||
const syncChatTypes = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
let resolvedChatTypeSnapshot: ChatTypeRegistrySnapshot = {
|
||||
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||
customChatTypes: [],
|
||||
chatTypes: DEFAULT_CHAT_TYPES,
|
||||
};
|
||||
|
||||
try {
|
||||
const serverChatTypes = await fetchChatTypesFromServer();
|
||||
const resolvedChatTypes = serverChatTypes ?? DEFAULT_CHAT_TYPES;
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setChatTypesState(resolvedChatTypes);
|
||||
}
|
||||
const serverChatTypeSnapshot = await fetchChatTypesFromServer();
|
||||
resolvedChatTypeSnapshot = serverChatTypeSnapshot ?? resolvedChatTypeSnapshot;
|
||||
applySnapshot(resolvedChatTypeSnapshot);
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
setBuiltInChatTypesState(sanitizeChatTypes(DEFAULT_CHAT_TYPES));
|
||||
setCustomChatTypesState([]);
|
||||
setChatTypesState(DEFAULT_CHAT_TYPES);
|
||||
setErrorMessage(error instanceof Error ? error.message : '채팅유형을 불러오지 못했습니다.');
|
||||
}
|
||||
resolvedChatTypeSnapshot = {
|
||||
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||
customChatTypes: [],
|
||||
chatTypes: DEFAULT_CHAT_TYPES,
|
||||
};
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedChatTypeSnapshot;
|
||||
};
|
||||
|
||||
syncChatTypesRef.current = syncChatTypes;
|
||||
|
||||
void syncChatTypes();
|
||||
|
||||
const handleSync = () => {
|
||||
@@ -310,14 +384,15 @@ export function useChatTypeRegistry() {
|
||||
|
||||
return {
|
||||
chatTypes,
|
||||
builtInChatTypes,
|
||||
customChatTypes,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
reload: async () => syncChatTypesRef.current(),
|
||||
setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => {
|
||||
const resolved = await saveChatTypesToServer(nextChatTypes);
|
||||
if (isMountedRef.current) {
|
||||
setChatTypesState(resolved);
|
||||
setErrorMessage('');
|
||||
}
|
||||
await saveChatTypesToServer(nextChatTypes);
|
||||
const resolved = await fetchChatTypesFromServer();
|
||||
applySnapshot(resolved);
|
||||
return resolved;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,7 +10,17 @@ export type DefaultChatTypeRecord = {
|
||||
export const GENERAL_REQUEST_CHAT_TYPE_ID = 'general-request';
|
||||
export const GENERAL_REQUEST_CHAT_TYPE_NAME = '일반 요청';
|
||||
export const GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
|
||||
|
||||
export const MD_CONTEXT_MANAGED_CHAT_TYPE_ID = 'md-context-managed';
|
||||
export const MD_CONTEXT_MANAGED_CHAT_TYPE_NAME = 'MD 기준 관리';
|
||||
export const MD_CONTEXT_MANAGED_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.';
|
||||
|
||||
export const CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_ID = 'chat-maximized-bottom-safe';
|
||||
export const CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_NAME = '채팅 최대화 하단 안전영역';
|
||||
export const CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 채팅 화면을 최대화한 상태에서도 최하단 입력영역과 마지막 액션이 가려지지 않도록 우선 확인합니다.\n- 하단 UI를 수정할 때는 메시지 스크롤 여백, 시스템 상태 영역, composer safe-area를 함께 점검합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경에서 최대화 후 최하단까지 스크롤한 상태로 진행합니다.\n- 최하단 입력창, 전송 버튼, 상태영역 bottom 좌표가 viewport 안에 남는지 확인합니다.\n- 최종 검증 이미지는 `[[preview:URL]]`로 제공합니다.\n\n## 구현 기준\n- 모달, 드로어, sticky 액션이 기존 하단 입력영역을 덮지 않게 유지합니다.\n- 이전 처리에서 불필요해진 하단 보정 CSS는 함께 정리합니다.';
|
||||
|
||||
export const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
|
||||
export const LAYOUT_EDITOR_CHAT_TYPE_NAME = 'Layout editor 실행';
|
||||
@@ -39,7 +49,23 @@ export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
|
||||
description: GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: MD_CONTEXT_MANAGED_CHAT_TYPE_ID,
|
||||
name: MD_CONTEXT_MANAGED_CHAT_TYPE_NAME,
|
||||
description: MD_CONTEXT_MANAGED_CHAT_TYPE_DESCRIPTION,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_ID,
|
||||
name: CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_NAME,
|
||||
description: CHAT_MAXIMIZED_BOTTOM_SAFE_CHAT_TYPE_DESCRIPTION,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
|
||||
|
||||
@@ -47,6 +47,7 @@ export function ConversationRoomPane({
|
||||
showScrollToBottom={false}
|
||||
copiedMessageId={null}
|
||||
draft=""
|
||||
draftVersion={0}
|
||||
composerAttachments={[]}
|
||||
requestStateMap={requestStateMap}
|
||||
isConversationLoading={isLoading}
|
||||
@@ -85,6 +86,7 @@ export function ConversationRoomPane({
|
||||
onCancelMessage={() => {}}
|
||||
onDeleteRequest={() => {}}
|
||||
onRemoveQueuedRequest={() => {}}
|
||||
onSubmitPrompt={async () => false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
clearChatConversationRoom,
|
||||
createChatConversationRoom,
|
||||
deleteChatConversationRequest,
|
||||
deleteChatConversationRoom,
|
||||
@@ -57,6 +58,7 @@ export type ChatGateway = {
|
||||
>
|
||||
>,
|
||||
) => Promise<ChatConversationSummary>;
|
||||
clearConversation: (sessionId: string) => Promise<ChatConversationSummary>;
|
||||
deleteConversation: (sessionId: string) => Promise<void>;
|
||||
deleteConversationRequest: (sessionId: string, requestId: string) => Promise<void>;
|
||||
markConversationRead: (sessionId: string) => Promise<void>;
|
||||
@@ -73,6 +75,7 @@ export const chatGateway: ChatGateway = {
|
||||
createConversation: createChatConversationRoom,
|
||||
renameConversation: renameChatConversationRoom,
|
||||
updateConversation: updateChatConversationRoom,
|
||||
clearConversation: clearChatConversationRoom,
|
||||
deleteConversation: async (sessionId) => {
|
||||
await deleteChatConversationRoom(sessionId);
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ type UseConversationComposerControllerOptions = {
|
||||
maxContextMessages: number;
|
||||
maxContextChars: number;
|
||||
};
|
||||
draft: string;
|
||||
getDraft: () => string;
|
||||
composerAttachments: ChatComposerAttachment[];
|
||||
isComposerAttachmentUploading: boolean;
|
||||
selectedChatType: SelectedChatType;
|
||||
@@ -74,7 +74,17 @@ type UseConversationComposerControllerOptions = {
|
||||
setShowScrollToBottom: (value: boolean) => void;
|
||||
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
|
||||
upsertRequestItem: (request: ChatConversationRequest) => void;
|
||||
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
|
||||
syncConversationPreviewForRequest: (
|
||||
sessionId: string,
|
||||
text: string,
|
||||
requestedAt?: string,
|
||||
options?: {
|
||||
requestId?: string;
|
||||
mode?: 'queue' | 'direct';
|
||||
queueSize?: number;
|
||||
jobMessage?: string | null;
|
||||
},
|
||||
) => void;
|
||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||
createLocalMessage: (text: string) => ChatMessage;
|
||||
createChatMessage: (author: 'user' | 'codex' | 'system', text: string, requestId?: string | null) => ChatMessage;
|
||||
@@ -97,7 +107,7 @@ type SendMessageOptions = {
|
||||
export function useConversationComposerController({
|
||||
activeSessionId,
|
||||
appConfigChat,
|
||||
draft,
|
||||
getDraft,
|
||||
composerAttachments,
|
||||
isComposerAttachmentUploading,
|
||||
selectedChatType,
|
||||
@@ -268,7 +278,12 @@ export function useConversationComposerController({
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
});
|
||||
syncConversationPreviewForRequest(activeSessionId, text, queuedAt);
|
||||
syncConversationPreviewForRequest(activeSessionId, text, queuedAt, {
|
||||
requestId,
|
||||
mode: 'queue',
|
||||
queueSize: 1,
|
||||
jobMessage: '대기열 등록 중',
|
||||
});
|
||||
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
@@ -304,6 +319,12 @@ export function useConversationComposerController({
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
});
|
||||
syncConversationPreviewForRequest(activeSessionId, text, new Date().toISOString(), {
|
||||
requestId,
|
||||
mode: 'direct',
|
||||
queueSize: 0,
|
||||
jobMessage: '즉시 요청 실행 대기 중',
|
||||
});
|
||||
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
@@ -374,7 +395,7 @@ export function useConversationComposerController({
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
|
||||
const trimmed = buildOutgoingMessageText(draftText ?? getDraft(), composerAttachments).trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
@@ -423,7 +444,7 @@ export function useConversationComposerController({
|
||||
buildOutgoingMessageText,
|
||||
composerAttachments,
|
||||
createLocalMessage,
|
||||
draft,
|
||||
getDraft,
|
||||
executeSendMessage,
|
||||
isComposerAttachmentUploading,
|
||||
messagesRef,
|
||||
|
||||
@@ -48,6 +48,32 @@ function mergeConversationItemsPreservingRequestedSession(
|
||||
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();
|
||||
|
||||
@@ -359,8 +359,51 @@ export function useConversationRoomActionsController({
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearConversation = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
const item = await chatGateway.clearConversation(sessionId);
|
||||
sessionMessageCacheRef.current.set(sessionId, []);
|
||||
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === sessionId ? item : entry)));
|
||||
|
||||
if (sessionId === activeSessionId) {
|
||||
chatConnectionGateway.resetLastReceivedEventId(sessionId);
|
||||
setMessages([]);
|
||||
setRequestItems([]);
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
setCopiedMessageId(null);
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus('채팅방 데이터를 초기화했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
setIsResourceStripOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '채팅방 데이터 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
messageApi,
|
||||
sessionMessageCacheRef,
|
||||
setActivePreviewId,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setConversationItems,
|
||||
setCopiedMessageId,
|
||||
setDraft,
|
||||
setIsPreviewModalOpen,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
cancelPendingRequest,
|
||||
handleClearConversation,
|
||||
deleteStoredRequest,
|
||||
handleDeleteConversation,
|
||||
handleRenameConversation,
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
ChatMessage,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 6;
|
||||
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 6;
|
||||
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 8;
|
||||
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 8;
|
||||
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
|
||||
|
||||
function mergeConversationRequests(
|
||||
|
||||
@@ -54,6 +54,18 @@ export function appendClientIdHeader(headersInit?: HeadersInit) {
|
||||
headers.set('X-Client-Id', clientId);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const { origin, hostname } = window.location;
|
||||
|
||||
if (origin && !headers.has('X-App-Origin')) {
|
||||
headers.set('X-App-Origin', origin);
|
||||
}
|
||||
|
||||
if (hostname && !headers.has('X-App-Domain')) {
|
||||
headers.set('X-App-Domain', hostname);
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ export function MainLayout() {
|
||||
useGesturePageState('anyway');
|
||||
useGestureLayer({
|
||||
id: 'main-layout',
|
||||
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === 'worklogs'),
|
||||
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === DOCS_DEFAULT_FOLDER),
|
||||
gestures: [
|
||||
{
|
||||
id: 'mobile-top-right-pull-alert',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { docsMarkdownEntries } from '../../manifests/docs.manifest';
|
||||
import { componentSampleEntries, widgetSampleEntries } from '../../manifests/samples.manifest';
|
||||
import { DOCS_DEFAULT_FOLDER } from '../routes';
|
||||
|
||||
const DOCS_FOLDER_ORDER = ['worklogs', 'features', 'components', 'templates'] as const;
|
||||
const DOCS_FOLDER_ORDER = ['project'] as const;
|
||||
|
||||
export function useMainLayoutData() {
|
||||
const [componentSamples, setComponentSamples] = useState<LoadedSampleEntry[]>([]);
|
||||
|
||||
323
src/app/main/mainChatPanel/ChatActivityChecklist.tsx
Normal file
323
src/app/main/mainChatPanel/ChatActivityChecklist.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleFilled,
|
||||
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';
|
||||
|
||||
type ActivityChecklistEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
state: ActivityChecklistState;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const CHECKLIST_STAGE_ORDER: ActivityChecklistStageKey[] = ['intake', 'analysis', 'inspection', 'execution', 'result'];
|
||||
|
||||
const CHECKLIST_STAGE_LABELS: Record<ActivityChecklistStageKey, string> = {
|
||||
intake: '요청 접수',
|
||||
analysis: '요청 분석',
|
||||
inspection: '관련 확인',
|
||||
execution: '구현·응답 작성',
|
||||
result: '검증·결과 정리',
|
||||
};
|
||||
|
||||
const CHECKLIST_STAGE_PATTERNS: Record<ActivityChecklistStageKey, RegExp[]> = {
|
||||
intake: [/요청을 접수/i, /대기열 등록/i, /즉시 실행 대기/i, /요청을 처리합니다/i],
|
||||
analysis: [/요청 분석/i, /분석/i, /생각 중/i, /의도/i, /문맥/i],
|
||||
inspection: [
|
||||
/\bdb\b/i,
|
||||
/데이터베이스/i,
|
||||
/\bapi\b/i,
|
||||
/엔드포인트/i,
|
||||
/응답/i,
|
||||
/소스/i,
|
||||
/코드/i,
|
||||
/파일/i,
|
||||
/흐름/i,
|
||||
/쿼리/i,
|
||||
/집계/i,
|
||||
/resource/i,
|
||||
/리소스/i,
|
||||
/화면/i,
|
||||
],
|
||||
execution: [/구현/i, /수정/i, /변경/i, /작성/i, /빌드/i, /patch/i, /diff/i, /실시간으로 전송 중/i],
|
||||
result: [/검증/i, /테스트/i, /캡처/i, /preview/i, /스크린샷/i, /완료/i, /결과/i, /정리/i],
|
||||
};
|
||||
|
||||
function stripActivityPrefix(line: string) {
|
||||
return line.replace(/^#\s*(상태|진행|이유|경고|오류):\s*/u, '').trim();
|
||||
}
|
||||
|
||||
function sanitizeActivitySummary(value: string) {
|
||||
const candidates = stripActivityPrefix(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter(
|
||||
(line) =>
|
||||
!line.startsWith('$ ') &&
|
||||
!line.startsWith('# 결과:') &&
|
||||
!line.startsWith('# 출력:') &&
|
||||
!line.startsWith('# command-runner') &&
|
||||
!/^\[(stderr|stdout)\]/i.test(line),
|
||||
);
|
||||
|
||||
const summary = candidates[0] ?? '';
|
||||
|
||||
if (!summary) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return summary.length > 160 ? `${summary.slice(0, 157).trimEnd()}...` : summary;
|
||||
}
|
||||
|
||||
function normalizeLines(lines: string[]) {
|
||||
return lines.map((line) => String(line ?? '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function getLastStageSummary(lines: string[], stageKey: ActivityChecklistStageKey) {
|
||||
const patterns = CHECKLIST_STAGE_PATTERNS[stageKey];
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = sanitizeActivitySummary(lines[index] ?? '');
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patterns.some((pattern) => pattern.test(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveObservationSummary(lines: string[]) {
|
||||
const labels = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const normalized = sanitizeActivitySummary(line);
|
||||
|
||||
if (/\bdb\b/i.test(normalized) || /데이터베이스|sql|쿼리|집계/i.test(normalized)) {
|
||||
labels.add('DB');
|
||||
}
|
||||
|
||||
if (/\bapi\b/i.test(normalized) || /엔드포인트|fetch|호출|응답/i.test(normalized)) {
|
||||
labels.add('API');
|
||||
}
|
||||
|
||||
if (/소스|코드|파일|tsx|ts|js|css|흐름/i.test(normalized)) {
|
||||
labels.add('소스');
|
||||
}
|
||||
|
||||
if (/화면|리소스|preview|캡처|스크린샷/i.test(normalized)) {
|
||||
labels.add('화면');
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(labels).join(' · ');
|
||||
}
|
||||
|
||||
function resolveCurrentStageKey(lines: string[], request?: ChatConversationRequest) {
|
||||
if (request?.status === 'completed' || request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed') {
|
||||
return 'result' as const;
|
||||
}
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = sanitizeActivitySummary(lines[index] ?? '');
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const stageKey of ['result', 'execution', 'inspection', 'analysis', 'intake'] as const) {
|
||||
if (CHECKLIST_STAGE_PATTERNS[stageKey].some((pattern) => pattern.test(candidate))) {
|
||||
return stageKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request?.status === 'started') {
|
||||
return 'analysis' as const;
|
||||
}
|
||||
|
||||
return 'intake' as const;
|
||||
}
|
||||
|
||||
function resolveResultNote(request?: ChatConversationRequest) {
|
||||
const normalizedStatusMessage = String(request?.statusMessage ?? '').trim();
|
||||
|
||||
if (request?.status === 'completed') {
|
||||
return normalizedStatusMessage || '응답과 결과 정리가 완료되었습니다.';
|
||||
}
|
||||
|
||||
if (request?.status === 'failed') {
|
||||
return normalizedStatusMessage || '오류로 종료되었습니다.';
|
||||
}
|
||||
|
||||
if (request?.status === 'cancelled') {
|
||||
return normalizedStatusMessage || '사용자 요청으로 중단되었습니다.';
|
||||
}
|
||||
|
||||
if (request?.status === 'removed') {
|
||||
return normalizedStatusMessage || '요청 기록이 제거되었습니다.';
|
||||
}
|
||||
|
||||
return normalizedStatusMessage || '최종 결과를 정리하는 단계입니다.';
|
||||
}
|
||||
|
||||
function buildChecklistEntries(lines: string[], request?: ChatConversationRequest) {
|
||||
const currentStageKey = resolveCurrentStageKey(lines, request);
|
||||
const currentStageIndex = CHECKLIST_STAGE_ORDER.indexOf(currentStageKey);
|
||||
const isTerminalComplete = request?.status === 'completed';
|
||||
const isTerminalError = request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed';
|
||||
const observationSummary = resolveObservationSummary(lines);
|
||||
|
||||
return CHECKLIST_STAGE_ORDER.map<ActivityChecklistEntry>((stageKey, index) => {
|
||||
const summary = getLastStageSummary(lines, stageKey);
|
||||
let state: ActivityChecklistState = 'pending';
|
||||
|
||||
if (isTerminalComplete) {
|
||||
state = 'complete';
|
||||
} else if (isTerminalError && index === currentStageIndex) {
|
||||
state = 'error';
|
||||
} else if (index < currentStageIndex) {
|
||||
state = 'complete';
|
||||
} else if (index === currentStageIndex) {
|
||||
state = 'current';
|
||||
}
|
||||
|
||||
let note = summary;
|
||||
|
||||
if (!note) {
|
||||
switch (stageKey) {
|
||||
case 'intake':
|
||||
note = request?.status === 'queued' ? '대기열 접수 후 순차 실행을 기다립니다.' : '요청을 접수하고 실행 준비를 시작합니다.';
|
||||
break;
|
||||
case 'analysis':
|
||||
note = '요청 의도와 현재 화면 문맥을 정리합니다.';
|
||||
break;
|
||||
case 'inspection':
|
||||
note = observationSummary ? `${observationSummary} 기준으로 확인합니다.` : 'DB, API, 소스, 화면 중 필요한 대상을 확인합니다.';
|
||||
break;
|
||||
case 'execution':
|
||||
note = request?.hasResponse ? '응답 초안 또는 변경 결과를 작성 중입니다.' : '필요한 구현과 응답 작성을 진행합니다.';
|
||||
break;
|
||||
case 'result':
|
||||
note = resolveResultNote(request);
|
||||
break;
|
||||
}
|
||||
} else if (stageKey === 'inspection' && observationSummary && !note.includes('·')) {
|
||||
note = `${note} (${observationSummary})`;
|
||||
}
|
||||
|
||||
return {
|
||||
key: stageKey,
|
||||
label: CHECKLIST_STAGE_LABELS[stageKey],
|
||||
state,
|
||||
note,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderStateIcon(state: ActivityChecklistState) {
|
||||
if (state === 'complete') {
|
||||
return <CheckCircleFilled aria-hidden="true" />;
|
||||
}
|
||||
|
||||
if (state === 'current') {
|
||||
return <LoadingOutlined aria-hidden="true" />;
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
return <CloseCircleFilled aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return <ClockCircleOutlined aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function buildSummaryLabel(entries: ActivityChecklistEntry[]) {
|
||||
const completedCount = entries.filter((entry) => entry.state === 'complete').length;
|
||||
const currentEntry = entries.find((entry) => entry.state === 'current');
|
||||
const errorEntry = entries.find((entry) => entry.state === 'error');
|
||||
|
||||
if (errorEntry) {
|
||||
return `${errorEntry.label} 단계에서 확인 필요`;
|
||||
}
|
||||
|
||||
if (currentEntry) {
|
||||
return `${completedCount}/${entries.length} 완료 · ${currentEntry.label} 진행 중`;
|
||||
}
|
||||
|
||||
if (completedCount === entries.length) {
|
||||
return '체크리스트 완료';
|
||||
}
|
||||
|
||||
return `${completedCount}/${entries.length} 완료`;
|
||||
}
|
||||
|
||||
export function ChatActivityChecklist({
|
||||
lines,
|
||||
request,
|
||||
chatTypeId,
|
||||
}: {
|
||||
lines: string[];
|
||||
request?: ChatConversationRequest;
|
||||
chatTypeId?: string | null;
|
||||
}) {
|
||||
if ((chatTypeId ?? '').trim() !== GENERAL_REQUEST_CHAT_TYPE_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedLines = normalizeLines(lines);
|
||||
const entries = buildChecklistEntries(normalizedLines, request);
|
||||
|
||||
return (
|
||||
<section className="app-chat-activity-checklist" aria-label="Plan 체크리스트">
|
||||
<div className="app-chat-activity-checklist__header">
|
||||
<div className="app-chat-activity-checklist__title-group">
|
||||
<span className="app-chat-activity-checklist__title">Plan 체크리스트</span>
|
||||
<span className="app-chat-activity-checklist__summary">{buildSummaryLabel(entries)}</span>
|
||||
</div>
|
||||
<span className="app-chat-activity-checklist__legend">
|
||||
<MinusCircleOutlined aria-hidden="true" />
|
||||
<span>실시간 반영</span>
|
||||
</span>
|
||||
</div>
|
||||
<ol className="app-chat-activity-checklist__list">
|
||||
{entries.map((entry) => (
|
||||
<li
|
||||
key={entry.key}
|
||||
className={`app-chat-activity-checklist__item app-chat-activity-checklist__item--${entry.state}`}
|
||||
aria-current={entry.state === 'current' ? 'step' : undefined}
|
||||
>
|
||||
<span className="app-chat-activity-checklist__icon">{renderStateIcon(entry.state)}</span>
|
||||
<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>
|
||||
</div>
|
||||
<p className="app-chat-activity-checklist__note">{entry.note}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import {
|
||||
startTransition,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -45,7 +44,9 @@ import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
|
||||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
|
||||
import { ChatActivityChecklist } from './ChatActivityChecklist';
|
||||
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
|
||||
import { buildPromptResponseText, ChatPromptCard, type PromptDraftSelection } from './ChatPromptCard';
|
||||
import { openChatExternalLink } from './linkNavigation';
|
||||
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
|
||||
import { extractChatMessageParts } from './messageParts';
|
||||
@@ -109,6 +110,11 @@ type PendingComposerUpload = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type PendingPromptSelection = PromptDraftSelection & {
|
||||
promptTitle: string;
|
||||
target: Extract<ChatMessagePart, { type: 'prompt' }>;
|
||||
};
|
||||
|
||||
type PreviewFetchError = Error & {
|
||||
status?: number;
|
||||
};
|
||||
@@ -128,6 +134,7 @@ type MessageRenderPayload = {
|
||||
diffBlocks: string[];
|
||||
rankedLinkTargets: RankedLinkPreviewTarget[];
|
||||
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
|
||||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||||
};
|
||||
|
||||
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
|
||||
@@ -172,6 +179,11 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function isHtmlPreviewUrl(url: string) {
|
||||
const pathname = url.toLowerCase().split('?')[0] ?? '';
|
||||
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||||
}
|
||||
|
||||
function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
@@ -453,20 +465,23 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
|
||||
}
|
||||
|
||||
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
||||
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
|
||||
const seen = new Set<string>();
|
||||
const targets: InlinePreviewTarget[] = [];
|
||||
|
||||
for (const matchedUrl of matches) {
|
||||
const pushTarget = (matchedUrl: string, options?: { allowHtml?: boolean }) => {
|
||||
const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
|
||||
const kind = classifyInlinePreviewKind(normalizedUrl);
|
||||
|
||||
if (kind === 'file') {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain HTML artifact paths should stay as text unless the reply explicitly opts into preview rendering.
|
||||
if (!options?.allowHtml && isHtmlPreviewUrl(normalizedUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seen.has(normalizedUrl)) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(normalizedUrl);
|
||||
@@ -475,7 +490,14 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
||||
label: buildInlinePreviewLabel(normalizedUrl),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
extractAutoDetectedPreviewUrls(text).forEach((matchedUrl) => {
|
||||
pushTarget(matchedUrl);
|
||||
});
|
||||
extractHiddenPreviewUrls(text).forEach((matchedUrl) => {
|
||||
pushTarget(matchedUrl, { allowHtml: true });
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
@@ -558,6 +580,18 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload
|
||||
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||||
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
|
||||
const promptTargets = [
|
||||
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||||
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||||
].filter(
|
||||
(part, index, collection) =>
|
||||
collection.findIndex(
|
||||
(candidate) =>
|
||||
candidate.title === part.title &&
|
||||
candidate.options.map((option) => `${option.value}:${option.label}`).join(',') ===
|
||||
part.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||||
) === index,
|
||||
);
|
||||
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
|
||||
.map((match) => match[1]?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
@@ -572,6 +606,7 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload
|
||||
diffBlocks,
|
||||
rankedLinkTargets,
|
||||
linkCardTargets,
|
||||
promptTargets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -977,6 +1012,7 @@ type ChatConversationViewProps = {
|
||||
showScrollToBottom: boolean;
|
||||
copiedMessageId: number | null;
|
||||
draft: string;
|
||||
draftVersion: number;
|
||||
composerAttachments: ChatComposerAttachment[];
|
||||
requestStateMap: Map<string, ChatConversationRequest>;
|
||||
isConversationLoading: boolean;
|
||||
@@ -1015,6 +1051,7 @@ type ChatConversationViewProps = {
|
||||
onCancelMessage: (message: ChatMessage) => void;
|
||||
onDeleteRequest: (message: ChatMessage) => void;
|
||||
onRemoveQueuedRequest: (requestId: string) => void;
|
||||
onSubmitPrompt: (payload: { text: string; mode: 'queue' | 'direct' }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function ChatConversationView({
|
||||
@@ -1026,6 +1063,7 @@ export function ChatConversationView({
|
||||
showScrollToBottom,
|
||||
copiedMessageId,
|
||||
draft,
|
||||
draftVersion,
|
||||
composerAttachments,
|
||||
requestStateMap,
|
||||
isConversationLoading,
|
||||
@@ -1064,7 +1102,10 @@ export function ChatConversationView({
|
||||
onCancelMessage,
|
||||
onDeleteRequest,
|
||||
onRemoveQueuedRequest,
|
||||
onSubmitPrompt,
|
||||
}: ChatConversationViewProps) {
|
||||
const [composerDraft, setComposerDraft] = useState(draft);
|
||||
const [pendingPromptSelections, setPendingPromptSelections] = useState<Record<string, PendingPromptSelection>>({});
|
||||
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
||||
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
||||
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
||||
@@ -1073,37 +1114,41 @@ export function ChatConversationView({
|
||||
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
||||
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
||||
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
|
||||
const [composerDraft, setComposerDraft] = useState(draft);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
|
||||
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
||||
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
|
||||
const lastReportedDraftRef = useRef(draft);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft === lastReportedDraftRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setComposerDraft(draft);
|
||||
}, [draft]);
|
||||
}, [draft, draftVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (composerDraft === lastReportedDraftRef.current) {
|
||||
return;
|
||||
const pendingPromptSelectionEntries = useMemo(
|
||||
() => Object.entries(pendingPromptSelections).sort(([left], [right]) => left.localeCompare(right)),
|
||||
[pendingPromptSelections],
|
||||
);
|
||||
const pendingPromptSelectionCount = pendingPromptSelectionEntries.length;
|
||||
|
||||
const buildComposerOutboundText = (draftText: string) => {
|
||||
const trimmedDraftText = draftText.trim();
|
||||
|
||||
if (pendingPromptSelectionEntries.length === 0) {
|
||||
return draftText;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
lastReportedDraftRef.current = composerDraft;
|
||||
startTransition(() => {
|
||||
onDraftChange(composerDraft);
|
||||
});
|
||||
}, 120);
|
||||
const promptTexts = pendingPromptSelectionEntries.map(([, selection], index) => {
|
||||
const mergedFreeText = [selection.freeText.trim(), index === pendingPromptSelectionEntries.length - 1 ? trimmedDraftText : '']
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [composerDraft, onDraftChange]);
|
||||
return buildPromptResponseText(selection.target, {
|
||||
...selection,
|
||||
freeText: mergedFreeText,
|
||||
});
|
||||
});
|
||||
|
||||
return promptTexts.join('\n\n');
|
||||
};
|
||||
|
||||
const orderedMessages = useMemo(() => {
|
||||
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
|
||||
@@ -1178,6 +1223,39 @@ export function ChatConversationView({
|
||||
|
||||
return [...ordered, ...orphanActivityMessages];
|
||||
}, [requestStateMap, visibleMessages]);
|
||||
const lastNonSystemMessageId = useMemo(() => {
|
||||
for (let index = orderedMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = orderedMessages[index];
|
||||
if (message.author !== 'system') {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [orderedMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const activePromptKeys = new Set<string>();
|
||||
|
||||
orderedMessages.forEach((message) => {
|
||||
const { promptTargets } = extractMessageRenderPayload(message);
|
||||
|
||||
promptTargets.forEach((target, index) => {
|
||||
activePromptKeys.add(`${message.id}:${index}:${target.title}`);
|
||||
});
|
||||
});
|
||||
|
||||
setPendingPromptSelections((current) => {
|
||||
const nextEntries = Object.entries(current).filter(([key]) => activePromptKeys.has(key));
|
||||
|
||||
if (nextEntries.length === Object.keys(current).length) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return Object.fromEntries(nextEntries);
|
||||
});
|
||||
}, [orderedMessages]);
|
||||
|
||||
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
||||
const isChatTypeReadonly = isChatTypeSelectionLocked;
|
||||
const visiblePreviewItems = useMemo(() => {
|
||||
@@ -1546,8 +1624,12 @@ export function ChatConversationView({
|
||||
const composerPlaceholder = isComposerDisabled
|
||||
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
||||
: isMobileViewport
|
||||
? '메시지를 입력하세요.'
|
||||
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
||||
? pendingPromptSelectionCount > 0
|
||||
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다.'
|
||||
: '메시지를 입력하세요.'
|
||||
: pendingPromptSelectionCount > 0
|
||||
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다. Ctrl+Enter로 바로 전송할 수 있습니다.'
|
||||
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
||||
|
||||
const renderActivityCard = (message: ChatMessage) => {
|
||||
const requestId = message.clientRequestId?.trim() || String(message.id);
|
||||
@@ -1555,6 +1637,7 @@ export function ChatConversationView({
|
||||
const lines = extractActivityLines(message);
|
||||
const liveStatusLine = summarizeActivityLines(lines) || '활동 로그를 불러오는 중입니다.';
|
||||
const activityCountLabel = `${lines.length}개 로그`;
|
||||
const request = requestStateMap.get(requestId);
|
||||
|
||||
return (
|
||||
<div key={`activity-${message.id}`} className="app-chat-message-stack app-chat-message-stack--system">
|
||||
@@ -1601,9 +1684,12 @@ export function ChatConversationView({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-chat-preview-card__body app-chat-preview-card__body--activity app-chat-preview-card__body--activity-summary">
|
||||
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
|
||||
<span className="app-chat-activity-card__summary-label">현재 상태</span>
|
||||
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
|
||||
<div className="app-chat-activity-card__summary-grid">
|
||||
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
|
||||
<span className="app-chat-activity-card__summary-label">현재 상태</span>
|
||||
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
|
||||
</div>
|
||||
<ChatActivityChecklist lines={lines} request={request} chatTypeId={selectedChatTypeId} />
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
@@ -1748,7 +1834,8 @@ export function ChatConversationView({
|
||||
const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
|
||||
isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : ''
|
||||
}`;
|
||||
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
|
||||
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets, promptTargets } =
|
||||
extractMessageRenderPayload(message);
|
||||
const renderedText = isRecoveredMissingRequest
|
||||
? getMissingRequestMessageText(message)
|
||||
: isRecoveredExecutionFailure
|
||||
@@ -1761,9 +1848,14 @@ export function ChatConversationView({
|
||||
|
||||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||||
const hasPreviewCards =
|
||||
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
|
||||
diffBlocks.length > 0 ||
|
||||
inlinePreviewTargets.length > 0 ||
|
||||
rankedLinkTargets.length > 0 ||
|
||||
linkCardTargets.length > 0 ||
|
||||
promptTargets.length > 0;
|
||||
const shouldRenderStandalonePreview =
|
||||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||||
const isPromptReadOnly = message.id !== lastNonSystemMessageId;
|
||||
const stackClassName = [
|
||||
`app-chat-message-stack app-chat-message-stack--${message.author}`,
|
||||
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
|
||||
@@ -1909,6 +2001,42 @@ export function ChatConversationView({
|
||||
{linkCardTargets.map((target) => (
|
||||
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||
))}
|
||||
{promptTargets.map((target, index) => (
|
||||
(() => {
|
||||
const selectionKey = `${message.id}:${index}:${target.title}`;
|
||||
|
||||
return (
|
||||
<ChatPromptCard
|
||||
key={`${message.id}-prompt-${index}-${target.title}`}
|
||||
target={target}
|
||||
onSubmit={onSubmitPrompt}
|
||||
readOnly={isPromptReadOnly}
|
||||
onSelectionChange={(selection) => {
|
||||
setPendingPromptSelections((current) => {
|
||||
if (!selection) {
|
||||
if (!(selectionKey in current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next = { ...current };
|
||||
delete next[selectionKey];
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[selectionKey]: {
|
||||
...selection,
|
||||
promptTitle: target.title,
|
||||
target,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
{rankedLinkTargets.map((target) => (
|
||||
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
|
||||
))}
|
||||
@@ -2064,9 +2192,7 @@ export function ChatConversationView({
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label="즉시 요청"
|
||||
onClick={() => {
|
||||
lastReportedDraftRef.current = composerDraft;
|
||||
onDraftChange(composerDraft);
|
||||
onSendImmediate(composerDraft);
|
||||
onSendImmediate(buildComposerOutboundText(composerDraft));
|
||||
}}
|
||||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||||
/>
|
||||
@@ -2075,9 +2201,7 @@ export function ChatConversationView({
|
||||
icon={<SendOutlined />}
|
||||
aria-label="큐로 보내기"
|
||||
onClick={() => {
|
||||
lastReportedDraftRef.current = composerDraft;
|
||||
onDraftChange(composerDraft);
|
||||
onSend(composerDraft);
|
||||
onSend(buildComposerOutboundText(composerDraft));
|
||||
}}
|
||||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||||
/>
|
||||
@@ -2096,6 +2220,27 @@ export function ChatConversationView({
|
||||
|
||||
{composerAttachmentStrip}
|
||||
|
||||
{pendingPromptSelectionCount > 0 ? (
|
||||
<div className="app-chat-panel__composer-prompt-strip" aria-live="polite">
|
||||
{pendingPromptSelectionEntries.map(([selectionKey, selection]) => {
|
||||
const selectionLabel = selection.target.options
|
||||
.filter((option) => selection.selectedValues.includes(option.value))
|
||||
.map((option) => option.label)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<div key={selectionKey} className="app-chat-panel__composer-prompt-chip">
|
||||
<span className="app-chat-panel__composer-prompt-chip-title">{selection.promptTitle}</span>
|
||||
<span className="app-chat-panel__composer-prompt-chip-value">
|
||||
{selection.summaryText || selectionLabel || selection.selectedValues.join(', ')}
|
||||
</span>
|
||||
<span className="app-chat-panel__composer-prompt-chip-meta">일반 전송에 포함됨</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`app-chat-panel__composer-input-shell${
|
||||
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
|
||||
@@ -2138,7 +2283,9 @@ export function ChatConversationView({
|
||||
placeholder={composerPlaceholder}
|
||||
disabled={isComposerDisabled}
|
||||
onChange={(event) => {
|
||||
setComposerDraft(event.target.value);
|
||||
const nextValue = event.target.value;
|
||||
setComposerDraft(nextValue);
|
||||
onDraftChange(nextValue);
|
||||
}}
|
||||
onPaste={handleComposerPaste}
|
||||
onKeyDown={(event) => {
|
||||
@@ -2158,9 +2305,7 @@ export function ChatConversationView({
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
lastReportedDraftRef.current = event.currentTarget.value;
|
||||
onDraftChange(event.currentTarget.value);
|
||||
onSend(event.currentTarget.value);
|
||||
onSend(buildComposerOutboundText(composerDraft));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
|
||||
1160
src/app/main/mainChatPanel/ChatPromptCard.tsx
Normal file
1160
src/app/main/mainChatPanel/ChatPromptCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
const CHAT_PUBLIC_RESOURCE_MARKER = '/.codex_chat/';
|
||||
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||
|
||||
function extractEmbeddedResourcePath(value: string) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
@@ -11,13 +12,23 @@ function extractEmbeddedResourcePath(value: string) {
|
||||
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||
|
||||
if (apiMarkerIndex >= 0) {
|
||||
return normalized.slice(apiMarkerIndex);
|
||||
const apiPath = normalized.slice(apiMarkerIndex);
|
||||
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||
return dotCodexIndex >= 0
|
||||
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||
: apiPath;
|
||||
}
|
||||
|
||||
const publicMarkerIndex = normalized.lastIndexOf(CHAT_PUBLIC_RESOURCE_MARKER);
|
||||
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||
|
||||
if (publicMarkerIndex >= 0) {
|
||||
return normalized.slice(publicMarkerIndex);
|
||||
if (publicDotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||
}
|
||||
|
||||
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||
|
||||
if (dotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
|
||||
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
|
||||
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
|
||||
import { reportClientError } from '../errorLogApi';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import type {
|
||||
ChatActivityEvent,
|
||||
ChatConversationActivityLog,
|
||||
@@ -839,32 +840,7 @@ export async function diagnoseConnectionFailure(targetUrl: string, closeEvent?:
|
||||
}
|
||||
|
||||
export async function copyText(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('클립보드를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const copied = document.execCommand('copy');
|
||||
|
||||
if (!copied) {
|
||||
throw new Error('복사 명령이 거부되었습니다.');
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
return copyTextToClipboard(text);
|
||||
}
|
||||
|
||||
export type PreviewCopyResult = 'text' | 'image' | 'url';
|
||||
@@ -1419,6 +1395,23 @@ export async function deleteChatConversationRoom(sessionId: string) {
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function clearChatConversationRoom(sessionId: string) {
|
||||
const clientId = getOrCreateClientId();
|
||||
const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>(
|
||||
`/conversations/${encodeURIComponent(sessionId)}/clear`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteChatConversationRequest(sessionId: string, requestId: string) {
|
||||
const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string; requestId: string }>(
|
||||
`/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}`,
|
||||
@@ -1450,13 +1443,16 @@ function areChatMessagesEquivalent(left: ChatMessage[], right: ChatMessage[]) {
|
||||
|
||||
return left.every((message, index) => {
|
||||
const other = right[index];
|
||||
const leftParts = JSON.stringify(message.parts ?? []);
|
||||
const rightParts = JSON.stringify(other?.parts ?? []);
|
||||
|
||||
return (
|
||||
other &&
|
||||
message.id === other.id &&
|
||||
message.author === other.author &&
|
||||
message.text === other.text &&
|
||||
message.timestamp === other.timestamp
|
||||
message.timestamp === other.timestamp &&
|
||||
leftParts === rightParts
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1491,6 +1487,7 @@ export function upsertChatMessage(previous: ChatMessage[], incoming: ChatMessage
|
||||
...existingMessage,
|
||||
...incoming,
|
||||
text: nextText,
|
||||
parts: incoming.parts ?? existingMessage.parts ?? [],
|
||||
deliveryStatus: null,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
createIntroMessage,
|
||||
createLocalMessage,
|
||||
cancelChatRuntimeJob,
|
||||
clearChatConversationRoom,
|
||||
deleteChatConversationRequest,
|
||||
deleteChatConversationRoom,
|
||||
fetchChatConversationDetail,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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 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/';
|
||||
type PromptPart = Extract<ChatMessagePart, { type: 'prompt' }>;
|
||||
type PromptOption = PromptPart['options'][number];
|
||||
type PromptPreview = NonNullable<PromptOption['preview']>;
|
||||
type PromptStep = NonNullable<PromptPart['steps']>[number];
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
@@ -21,6 +29,25 @@ function normalizeUrl(value: string) {
|
||||
return `/${malformedResourceMatch[1]}`;
|
||||
}
|
||||
|
||||
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||
if (apiMarkerIndex >= 0) {
|
||||
const apiPath = normalized.slice(apiMarkerIndex);
|
||||
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||
return dotCodexIndex >= 0
|
||||
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||
: apiPath;
|
||||
}
|
||||
|
||||
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||
if (publicDotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||
}
|
||||
|
||||
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||
if (dotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||
}
|
||||
|
||||
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
@@ -28,6 +55,116 @@ function normalizeUrl(value: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizePromptPreview(value: unknown): PromptPreview | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const type =
|
||||
record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource'
|
||||
? record.type
|
||||
: null;
|
||||
const url = normalizeUrl(normalizeText(record.url));
|
||||
const content = String(record.content ?? '').trim() || null;
|
||||
const alt = normalizeText(record.alt) || null;
|
||||
const title = normalizeText(record.title) || null;
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'image' || type === 'resource') {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
} else if (!content && !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
url: url || null,
|
||||
content,
|
||||
alt,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function isPromptOption(value: PromptOption | null): value is PromptOption {
|
||||
return value != null;
|
||||
}
|
||||
|
||||
function normalizePromptOption(value: unknown): PromptOption | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const optionValue = normalizeText(record.value);
|
||||
const label = normalizeText(record.label);
|
||||
|
||||
if (!optionValue || !label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: optionValue,
|
||||
label,
|
||||
description: normalizeText(record.description) || null,
|
||||
preview: normalizePromptPreview(record.preview),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePromptSelectedValues(value: unknown) {
|
||||
return [
|
||||
...(Array.isArray(value) ? value : []),
|
||||
]
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean)
|
||||
.filter((item, index, array) => array.indexOf(item) === index);
|
||||
}
|
||||
|
||||
function normalizePromptSteps(value: unknown): PromptStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const key = normalizeText(record.key) || `step-${index + 1}`;
|
||||
const title = normalizeText(record.title);
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options.map((option) => normalizePromptOption(option)).filter(isPromptOption)
|
||||
: [];
|
||||
|
||||
if (!title || options.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key,
|
||||
title,
|
||||
description: normalizeText(record.description) || null,
|
||||
submitLabel: normalizeText(record.submitLabel) || null,
|
||||
mode: record.mode === 'direct' || record.mode === 'queue' ? record.mode : null,
|
||||
multiple: record.multiple === true,
|
||||
optional: record.optional === true,
|
||||
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
selectedValues: normalizePromptSelectedValues(record.selectedValues),
|
||||
options,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function decodeUrlComponentSafely(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
@@ -135,6 +272,64 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const title = normalizeText(record.title);
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options.map((item) => normalizePromptOption(item)).filter(isPromptOption)
|
||||
: [];
|
||||
const steps = normalizePromptSteps(record.steps);
|
||||
|
||||
if (!title || (options.length === 0 && steps.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = record.mode === 'direct' || record.mode === 'queue' ? record.mode : null;
|
||||
const selectedValues = [
|
||||
...normalizePromptSelectedValues(record.selectedValues),
|
||||
...(record.selectedValue != null ? [record.selectedValue] : []),
|
||||
]
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean)
|
||||
.filter((value, index, values) => values.indexOf(value) === index);
|
||||
const resolvedBy =
|
||||
record.resolvedBy === 'user' || record.resolvedBy === 'timeout' || record.resolvedBy === 'system'
|
||||
? record.resolvedBy
|
||||
: null;
|
||||
|
||||
return {
|
||||
type: 'prompt',
|
||||
title,
|
||||
description: normalizeText(record.description) || null,
|
||||
submitLabel: normalizeText(record.submitLabel) || null,
|
||||
mode,
|
||||
multiple: record.multiple === true,
|
||||
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
currentStepKey: normalizeText(record.currentStepKey) || null,
|
||||
steps: steps.length > 0 ? steps : undefined,
|
||||
readOnly: record.readOnly === true || selectedValues.length > 0,
|
||||
selectedValues,
|
||||
resolvedBy,
|
||||
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||
resultText: normalizeText(record.resultText) || null,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractChatMessageParts(text: string) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const keptLines: string[] = [];
|
||||
@@ -145,7 +340,38 @@ export function extractChatMessageParts(text: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
|
||||
const dedupeKey =
|
||||
nextPart.type === 'link_card'
|
||||
? `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`
|
||||
: [
|
||||
nextPart.type,
|
||||
nextPart.title,
|
||||
nextPart.options
|
||||
.map((option) =>
|
||||
[
|
||||
option.value,
|
||||
option.label,
|
||||
option.preview?.type ?? '',
|
||||
option.preview?.url ?? '',
|
||||
option.preview?.content ?? '',
|
||||
option.preview?.title ?? '',
|
||||
].join('|'),
|
||||
)
|
||||
.join(','),
|
||||
(nextPart.steps ?? [])
|
||||
.map((step) =>
|
||||
[
|
||||
step.key,
|
||||
step.title,
|
||||
step.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||||
].join('|'),
|
||||
)
|
||||
.join(','),
|
||||
nextPart.selectedValues?.join(',') ?? '',
|
||||
nextPart.resolvedBy ?? '',
|
||||
nextPart.resultText ?? '',
|
||||
nextPart.readOnly === true ? 'readonly' : '',
|
||||
].join(':');
|
||||
|
||||
if (seenLinkKeys.has(dedupeKey)) {
|
||||
return true;
|
||||
@@ -157,6 +383,15 @@ export function extractChatMessageParts(text: string) {
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const promptMatched = line.match(PROMPT_LINE_PATTERN);
|
||||
|
||||
if (promptMatched) {
|
||||
if (!pushPart(buildPromptPart(promptMatched[1] ?? ''))) {
|
||||
keptLines.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
||||
|
||||
if (!matched) {
|
||||
@@ -190,7 +425,7 @@ export function extractChatMessageParts(text: string) {
|
||||
}
|
||||
|
||||
const latestPart = parts.at(-1);
|
||||
if (latestPart && isInternalResourceUrl(latestPart.url)) {
|
||||
if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) {
|
||||
parts.pop();
|
||||
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
|
||||
keptLines.push(latestPart.url);
|
||||
|
||||
1019
src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css
Normal file
1019
src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css
Normal file
File diff suppressed because it is too large
Load Diff
1692
src/app/main/mainChatPanel/styles/MainChatPanel.layout.css
Normal file
1692
src/app/main/mainChatPanel/styles/MainChatPanel.layout.css
Normal file
File diff suppressed because it is too large
Load Diff
1980
src/app/main/mainChatPanel/styles/MainChatPanel.preview-runtime.css
Normal file
1980
src/app/main/mainChatPanel/styles/MainChatPanel.preview-runtime.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,64 @@ export type ChatMessagePart =
|
||||
title: string;
|
||||
url: string;
|
||||
actionLabel?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'prompt';
|
||||
title: string;
|
||||
description?: string | null;
|
||||
submitLabel?: string | null;
|
||||
mode?: 'queue' | 'direct' | null;
|
||||
multiple?: boolean;
|
||||
responseTemplate?: string | null;
|
||||
freeTextLabel?: string | null;
|
||||
freeTextPlaceholder?: string | null;
|
||||
currentStepKey?: string | null;
|
||||
steps?: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
submitLabel?: string | null;
|
||||
mode?: 'queue' | 'direct' | null;
|
||||
multiple?: boolean;
|
||||
optional?: boolean;
|
||||
responseTemplate?: string | null;
|
||||
freeTextLabel?: string | null;
|
||||
freeTextPlaceholder?: string | null;
|
||||
selectedValues?: string[];
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
preview?:
|
||||
| {
|
||||
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||
url?: string | null;
|
||||
content?: string | null;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
| null;
|
||||
}>;
|
||||
}>;
|
||||
readOnly?: boolean;
|
||||
selectedValues?: string[];
|
||||
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
||||
resolvedAt?: string | null;
|
||||
resultText?: string | null;
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
preview?:
|
||||
| {
|
||||
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||
url?: string | null;
|
||||
content?: string | null;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
| null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
@@ -58,6 +116,8 @@ export type ChatConversationSummary = {
|
||||
currentJobMessage: string | null;
|
||||
currentQueueSize: number;
|
||||
currentStatusUpdatedAt: string | null;
|
||||
isPendingWork: boolean;
|
||||
pendingWorkReason: 'prompt' | 'analysis' | 'design' | null;
|
||||
lastRequestPreview: string;
|
||||
lastMessagePreview: string;
|
||||
lastResponsePreview: string;
|
||||
|
||||
@@ -10,13 +10,10 @@ export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
|
||||
error: '실패 (0)',
|
||||
};
|
||||
|
||||
export const DOCS_DEFAULT_FOLDER = 'worklogs';
|
||||
export const DOCS_DEFAULT_FOLDER = 'project';
|
||||
|
||||
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
||||
worklogs: '작업일지',
|
||||
features: '기능문서',
|
||||
components: '컴포넌트문서',
|
||||
templates: '문서템플릿',
|
||||
project: '프로젝트 구조',
|
||||
};
|
||||
|
||||
export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
|
||||
|
||||
@@ -22,16 +22,13 @@ export type ChatSectionKey = 'live' | 'changes' | 'resources' | 'errors' | 'mana
|
||||
export type PlaySectionKey = 'layout' | 'test' | 'cbt';
|
||||
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
|
||||
|
||||
export const DOCS_DEFAULT_FOLDER = 'worklogs';
|
||||
export const DOCS_DEFAULT_FOLDER = 'project';
|
||||
export const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const;
|
||||
export const PLAN_MENU_STATUS_ORDER: PlanFilterStatus[] = ['all', 'in-progress', 'error', 'done'];
|
||||
export const PLAN_GROUP_LABEL = '작업';
|
||||
|
||||
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
||||
worklogs: '작업일지',
|
||||
features: '기능문서',
|
||||
components: '컴포넌트문서',
|
||||
templates: '문서템플릿',
|
||||
project: '프로젝트 구조',
|
||||
};
|
||||
|
||||
export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
|
||||
|
||||
@@ -10,26 +10,48 @@ const featureMarkdownModules = import.meta.glob('../../features/**/*.md', {
|
||||
import: 'default',
|
||||
}) as Record<string, () => Promise<string>>;
|
||||
|
||||
function createMarkdownEntries(
|
||||
const DOCS_ENTRY_DEFINITIONS: Array<Pick<MarkdownDocumentEntry, 'path' | 'folder' | 'title'>> = [
|
||||
{
|
||||
path: '/docs/README.md',
|
||||
folder: 'project',
|
||||
title: '프로젝트 구조',
|
||||
},
|
||||
];
|
||||
|
||||
const FEATURE_ENTRY_PATTERNS = [/\/overview\.md$/i, /\/README\.md$/i];
|
||||
|
||||
function createExplicitMarkdownEntries(
|
||||
modules: Record<string, () => Promise<string>>,
|
||||
entries: Array<Pick<MarkdownDocumentEntry, 'path' | 'folder' | 'title'>>,
|
||||
): MarkdownDocumentEntry[] {
|
||||
const sortedPaths = Object.keys(modules).sort((left, right) => {
|
||||
const isLeftWorklog = left.includes('/docs/worklogs/');
|
||||
const isRightWorklog = right.includes('/docs/worklogs/');
|
||||
|
||||
if (isLeftWorklog && isRightWorklog) {
|
||||
return right.localeCompare(left);
|
||||
}
|
||||
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
return sortedPaths.map((path, index) => ({
|
||||
path,
|
||||
load: modules[path],
|
||||
order: index,
|
||||
}));
|
||||
return entries
|
||||
.filter((entry) => typeof modules[entry.path] === 'function')
|
||||
.map((entry, index) => ({
|
||||
...entry,
|
||||
load: modules[entry.path],
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
export const docsMarkdownEntries = createMarkdownEntries(docsMarkdownModules);
|
||||
export const featureMarkdownEntries = createMarkdownEntries(featureMarkdownModules);
|
||||
function createFilteredMarkdownEntries(
|
||||
modules: Record<string, () => Promise<string>>,
|
||||
includePatterns: RegExp[],
|
||||
): MarkdownDocumentEntry[] {
|
||||
return Object.keys(modules)
|
||||
.filter((path) => includePatterns.some((pattern) => pattern.test(path)))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((path, index) => ({
|
||||
path,
|
||||
load: modules[path],
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
export const docsMarkdownEntries = createExplicitMarkdownEntries(
|
||||
docsMarkdownModules,
|
||||
DOCS_ENTRY_DEFINITIONS,
|
||||
);
|
||||
export const featureMarkdownEntries = createFilteredMarkdownEntries(
|
||||
featureMarkdownModules,
|
||||
FEATURE_ENTRY_PATTERNS,
|
||||
);
|
||||
|
||||
@@ -1,61 +1,25 @@
|
||||
# Components Package Guide
|
||||
# Components
|
||||
|
||||
`src/components`는 앱 전용 화면이 아니라 여러 화면과 샘플, 문서에서 공통 재사용할 UI 조각을 두는 패키지입니다. 컴포넌트 추가나 수정 시 이 문서를 기본 규약으로 사용합니다.
|
||||
`src/components`는 여러 화면에서 재사용하는 공통 UI 패키지입니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 화면 조합에 재사용되는 공통 UI를 보관합니다.
|
||||
- 라이브러리 export 대상과 앱 내부 재사용 대상을 같은 폴더 기준으로 관리합니다.
|
||||
- 컴포넌트 문서(`docs/components`)와 샘플(`samples`)의 기준 소스 역할을 합니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출, DB 접근, 라우팅, 화면 전용 상태, 비즈니스 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트 설계는 최대한 멍청하게 유지합니다. 직관적인 props를 받고, 그 props에 따라 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 상태 orchestration은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 어디에서나 재사용될 수 있으므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서 확장하거나 보완합니다.
|
||||
|
||||
## 현재 하위 구조
|
||||
|
||||
- `common`: 범용 보조 컴포넌트
|
||||
- `dashboard`: 진행률, 다중 progress 등 대시보드 계열 공통 UI
|
||||
- `dataListTable`, `dataStatePanel`, `embeddedMap`, `emptyIllustrationCard`, `evidenceAttachmentStrip`, `formField`, `markdownPreview`, `navigation`, `previewer`, `processFlow`, `queryFilterBuilder`, `search`, `stateKit`, `status-badge`, `stepper`, `timelinePanel`, `window`: 독립 재사용 가능한 컴포넌트 패키지
|
||||
- `inputs`: 입력 계열 공통 UI
|
||||
- `primitives`: 가장 작은 입력 단위
|
||||
- `specialized`: 목적이 뚜렷한 파생 입력
|
||||
- `composite`: 여러 입력을 묶은 조합형 UI
|
||||
- `select`, `checkCombo`, `popup`: plugin 확장과 샘플이 포함된 입력 패키지
|
||||
|
||||
## 폴더 구성 규약
|
||||
|
||||
컴포넌트 패키지는 가능하면 아래 구조를 따릅니다.
|
||||
## 구조
|
||||
|
||||
```text
|
||||
component-name/
|
||||
├─ ComponentName.tsx
|
||||
├─ ComponentName.css
|
||||
├─ index.ts
|
||||
├─ types/ # 외부 노출 타입 또는 내부 분리 타입
|
||||
├─ plugins/ # plugin factory 또는 preset
|
||||
└─ samples/ # Docs/APIs 화면에서 쓰는 예제
|
||||
src/components
|
||||
├─ common
|
||||
├─ inputs
|
||||
├─ markdownPreview
|
||||
├─ navigation
|
||||
├─ previewer
|
||||
└─ ...
|
||||
```
|
||||
|
||||
- 진입점은 항상 해당 폴더의 `index.ts`로 둡니다.
|
||||
- 외부에서 직접 import 해야 하는 타입은 `index.ts` 또는 `types/index.ts`를 통해 다시 export 합니다.
|
||||
- CSS가 필요하면 컴포넌트 폴더 내부에 함께 둡니다.
|
||||
- 복잡한 로직이 생기면 `types`, `plugins`, `samples`처럼 역할별 하위 폴더로 분리합니다.
|
||||
- `common`: 범용 보조 UI
|
||||
- `inputs`: 입력 계열 컴포넌트
|
||||
- 그 외 폴더: 독립 재사용 컴포넌트 패키지
|
||||
|
||||
## 구현 규약
|
||||
## 기준
|
||||
|
||||
- 공통 패키지에는 프로젝트 화면에 종속된 상태나 라우팅 의존을 넣지 않습니다.
|
||||
- 컴포넌트 이름, 파일명, export 이름은 PascalCase를 유지합니다. 폴더명은 기존 저장소 스타일대로 kebab-case 또는 lowerCamelCase를 따릅니다.
|
||||
- 라이브러리로 공개할 컴포넌트는 최종적으로 `src/index.ts`에서 다시 export 되어야 합니다.
|
||||
- 샘플이 필요한 컴포넌트는 `samples/Sample.tsx`를 기본 진입 예제로 두고, 변형 예제는 같은 폴더에 추가합니다.
|
||||
- plugin 확장형 컴포넌트는 `plugins/*.plugin.ts` 또는 `plugins/index.ts`에서 생성 함수를 모읍니다.
|
||||
- 공통 타입은 컴포넌트 폴더 안에서 우선 관리하고, 여러 컴포넌트가 공유할 때만 상위 공통 타입으로 승격합니다.
|
||||
|
||||
## 문서 규약
|
||||
|
||||
- 화면 사용법과 제약은 `docs/components/*.md`에 문서화합니다.
|
||||
- 새 컴포넌트를 추가하면 최소한 목적, 주요 props, 샘플 위치, plugin 여부를 문서에 남깁니다.
|
||||
- 패키지 구조나 규약이 바뀌면 이 문서와 해당 컴포넌트 문서를 함께 갱신합니다.
|
||||
- 화면 전용 상태와 비즈니스 로직은 넣지 않습니다.
|
||||
- 외부 진입점은 각 폴더의 `index.ts`를 사용합니다.
|
||||
- 복잡도가 커지면 `types`, `plugins`, `samples`로 분리합니다.
|
||||
|
||||
157
src/components/chatPromptCard/samples/Sample.tsx
Normal file
157
src/components/chatPromptCard/samples/Sample.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Flex, Typography } from 'antd';
|
||||
import type { SampleMeta } from '../../../widgets/core';
|
||||
import { ChatPromptCard } from '../../../app/main/mainChatPanel/ChatPromptCard';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'chat-prompt-card',
|
||||
componentId: 'chat-prompt-card',
|
||||
title: 'Chat Prompt Card',
|
||||
description: '채팅방 안에서 선택형 시안과 시간초과 자동선택 결과를 읽기 전용으로 보여주는 prompt 카드입니다.',
|
||||
category: 'Chat',
|
||||
kind: 'feature',
|
||||
variantLabel: 'Prompt',
|
||||
order: 95,
|
||||
features: ['docs'],
|
||||
};
|
||||
|
||||
export function Sample() {
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<Paragraph>
|
||||
첫 카드는 사용자가 직접 선택할 수 있는 상태이고, 두 번째 카드는 시간 초과 뒤 자동 선택된 결과를 읽기 전용으로 보여줍니다.
|
||||
</Paragraph>
|
||||
<ChatPromptCard
|
||||
target={{
|
||||
type: 'prompt',
|
||||
title: 'UI 수정 흐름 선택',
|
||||
description: '단계형 prompt를 통해 시안과 후속 작업 범위를 순서대로 정합니다.',
|
||||
submitLabel: '흐름 전달',
|
||||
mode: 'queue',
|
||||
options: [
|
||||
{
|
||||
label: '기본안',
|
||||
value: 'default',
|
||||
description: 'steps가 없을 때를 위한 fallback 옵션',
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
key: 'layout',
|
||||
title: '시안 선택',
|
||||
description: '아래 시안 중 하나를 골라 기본 레이아웃을 정합니다.',
|
||||
options: [
|
||||
{
|
||||
label: 'A안',
|
||||
value: 'option-a',
|
||||
description: '상단 헤더 강조와 큰 썸네일 중심 레이아웃',
|
||||
preview: {
|
||||
type: 'image',
|
||||
url: 'https://images.unsplash.com/photo-1519389950473-47ba0277781c?auto=format&fit=crop&w=900&q=80',
|
||||
alt: '대시보드 와이어프레임 샘플',
|
||||
title: 'A안 시안',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'B안',
|
||||
value: 'option-b',
|
||||
description: '중간 요약 카드와 탭 전환 중심 레이아웃',
|
||||
preview: {
|
||||
type: 'markdown',
|
||||
title: 'B안 요약',
|
||||
content: '## B안 핵심\n- 상단에 상태 요약 카드\n- 중간에 탭 3개\n- 하단 액션은 최소화',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'C안',
|
||||
value: 'option-c',
|
||||
description: '하단 고정 액션과 짧은 설명 중심 레이아웃',
|
||||
preview: {
|
||||
type: 'html',
|
||||
title: 'C안 레이아웃',
|
||||
content:
|
||||
'<section style="font-family:system-ui;padding:16px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#fff;border-radius:16px"><h3 style="margin:0 0 8px">C안</h3><p style="margin:0 0 12px">하단 고정 액션과 짧은 설명 중심</p><div style="display:grid;gap:8px"><div style="height:64px;background:rgba(255,255,255,.14);border-radius:12px"></div><div style="height:64px;background:rgba(255,255,255,.14);border-radius:12px"></div></div></section>',
|
||||
},
|
||||
},
|
||||
],
|
||||
responseTemplate: '{{selection_label}} 시안을 기본 레이아웃으로 채택했습니다.',
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
title: '후속 작업 범위',
|
||||
description: '선택 시안 기준으로 어떤 후속 작업을 이어갈지 고릅니다.',
|
||||
multiple: true,
|
||||
optional: true,
|
||||
options: [
|
||||
{
|
||||
label: '모바일 여백 정리',
|
||||
value: 'mobile-spacing',
|
||||
description: '모바일 화면 여백과 버튼 배치를 먼저 다듬습니다.',
|
||||
},
|
||||
{
|
||||
label: '상태 요약 추가',
|
||||
value: 'summary-card',
|
||||
description: '상단 요약 카드와 상태 문구를 함께 추가합니다.',
|
||||
},
|
||||
{
|
||||
label: '미리보기 문서 생성',
|
||||
value: 'preview-doc',
|
||||
description: '세션 리소스에 HTML/Markdown 시안을 같이 생성합니다.',
|
||||
},
|
||||
],
|
||||
freeTextLabel: '세부 요청',
|
||||
freeTextPlaceholder: '예: 첫 단계는 시안만 정하고 구현은 다음 응답에서 이어가세요.',
|
||||
},
|
||||
],
|
||||
responseTemplate: '사용자가 다음 단계형 흐름을 선택했습니다.\n{{step_summaries}}\n{{custom_text_block}}',
|
||||
}}
|
||||
onSubmit={async () => true}
|
||||
/>
|
||||
<ChatPromptCard
|
||||
target={{
|
||||
type: 'prompt',
|
||||
title: '작업 결과안 자동 선택',
|
||||
description: '응답 시간이 지나 시스템이 기본안을 선택했습니다.',
|
||||
readOnly: true,
|
||||
selectedValues: ['result-b'],
|
||||
resolvedBy: 'timeout',
|
||||
resultText: 'B안이 기본 시안으로 채택되었고, 다음 응답부터 이 흐름을 기준으로 이어갑니다.',
|
||||
options: [
|
||||
{
|
||||
label: '결과안 A',
|
||||
value: 'result-a',
|
||||
description: '카드형 설명을 크게 보여주는 결과안',
|
||||
preview: {
|
||||
type: 'image',
|
||||
url: 'https://images.unsplash.com/photo-1558655146-9f40138edfeb?auto=format&fit=crop&w=900&q=80',
|
||||
alt: '결과안 A 샘플',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '결과안 B',
|
||||
value: 'result-b',
|
||||
description: '선택 요약과 다음 액션을 한 줄로 정리한 결과안',
|
||||
preview: {
|
||||
type: 'markdown',
|
||||
content: '### 결과안 B\n선택 요약과 다음 액션을 한 줄로 정리합니다.',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '결과안 C',
|
||||
value: 'result-c',
|
||||
description: '추가 제안 링크를 함께 노출하는 결과안',
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/docs/index.md',
|
||||
title: '문서 리소스 예시',
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSubmit={async () => false}
|
||||
readOnly
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
EvidenceAttachmentPreviewBodyProps,
|
||||
EvidenceAttachmentStripProps,
|
||||
} from './types';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import './EvidenceAttachmentStrip.css';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
@@ -108,25 +109,7 @@ function getAttachmentTypeIcon(kind: EvidenceAttachmentKind): ReactNode {
|
||||
|
||||
async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
|
||||
const copyValue = attachment.copyValue ?? attachment.value;
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(copyValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('clipboard-unavailable');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = copyValue;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return copyTextToClipboard(copyValue);
|
||||
}
|
||||
|
||||
function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Empty, Segmented, Space, Tag, Typography, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import {
|
||||
CODEX_DIFF_STATUS_LABEL_MAP,
|
||||
CodexDiffBlock,
|
||||
@@ -100,30 +101,9 @@ export function CodexDiffPreviewer({
|
||||
const canShowDiff = Boolean(diffText);
|
||||
const resolvedMode = mode === 'auto' ? activeMode : mode;
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
async function handleCopy(content: string) {
|
||||
try {
|
||||
await copyText(content);
|
||||
await copyTextToClipboard(content);
|
||||
messageApi.success('복사했습니다.');
|
||||
} catch {
|
||||
messageApi.error('복사에 실패했습니다.');
|
||||
|
||||
@@ -169,17 +169,22 @@
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(22, 93, 255, 0.08);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.previewer-ui__markdown pre code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.previewer-ui__markdown pre {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
color: #dbe7ff;
|
||||
background: linear-gradient(180deg, #0f172a 0%, #111f39 100%);
|
||||
@@ -187,6 +192,10 @@
|
||||
}
|
||||
|
||||
.previewer-ui__editor {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
@@ -235,6 +244,10 @@
|
||||
}
|
||||
|
||||
.previewer-ui__editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button, Empty, Input, Select, message } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { InlineImage } from '../common/InlineImage';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import { CodexDiffBlock } from './CodexDiffBlock';
|
||||
import type { PreviewerUIProps } from './types';
|
||||
import { inferCodeLanguage, renderEditorBlock } from './renderers';
|
||||
@@ -121,27 +122,6 @@ function renderMarkdown(markdown: string) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function downloadBlob(content: BlobPart, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('다운로드를 사용할 수 없습니다.');
|
||||
@@ -340,7 +320,7 @@ export function PreviewerUI({
|
||||
}
|
||||
|
||||
try {
|
||||
await copyText(resolvedCopyValue);
|
||||
await copyTextToClipboard(resolvedCopyValue);
|
||||
messageApi.success('복사했습니다.');
|
||||
} catch {
|
||||
messageApi.error('복사에 실패했습니다.');
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
useAutomationTypeRegistry,
|
||||
} from '../../app/main/automationTypeAccess';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import {
|
||||
createBoardPost,
|
||||
deleteBoardPost,
|
||||
@@ -123,23 +124,6 @@ function resolveBoardAttachmentSessionId(
|
||||
return draftAttachmentSessionIdRef.current;
|
||||
}
|
||||
|
||||
async function copyText(value: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = value;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function hasBoardPostAutomation(item: BoardPost | null | undefined) {
|
||||
if (!item) {
|
||||
return false;
|
||||
@@ -562,7 +546,7 @@ export function BoardPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
await copyText(draft.content);
|
||||
await copyTextToClipboard(draft.content);
|
||||
messageApi.success('공통 메모를 복사했습니다.');
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '공통 메모 복사에 실패했습니다.');
|
||||
|
||||
@@ -1,50 +1,16 @@
|
||||
# Layout Feature
|
||||
|
||||
프로젝트 종속적인 레이아웃은 `src/features/layout` 아래에서 관리합니다.
|
||||
`src/features/layout`은 현재 프로젝트 전용 레이아웃 기능을 둡니다.
|
||||
|
||||
## 포함 항목
|
||||
## 포함 범위
|
||||
|
||||
- 컴포넌트 샘플 레이아웃
|
||||
- 위젯 샘플 레이아웃
|
||||
- Markdown preview 리스트 레이아웃
|
||||
- `Layout Editor`와 저장 레이아웃 흐름
|
||||
- 문서 미리보기 레이아웃
|
||||
- `Layout Editor`
|
||||
|
||||
## 규칙
|
||||
## 기준
|
||||
|
||||
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
|
||||
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
|
||||
|
||||
## Layout Editor 기준
|
||||
|
||||
`Layout Editor`는 위젯 자체의 기본 기능을 정의하는 화면이 아닙니다. 이 화면은 레이아웃 안에 배치된 컴포넌트가 현재 화면에서 어떤 역할을 해야 하는지 기술하는 편집기입니다.
|
||||
|
||||
용어 기준:
|
||||
|
||||
- `컴포넌트 기능 명세`: 현재 레이아웃에 배치된 컴포넌트의 화면 동작, 상호작용, 구현 포인트를 적는 항목
|
||||
- `저장 레이아웃`: 구조, 배치, 기능 명세를 함께 저장한 편집 결과
|
||||
- `Codex 요청`: 저장된 레이아웃과 그 안의 기능 명세를 기준으로 구현 요청을 만드는 액션
|
||||
|
||||
허용 범위:
|
||||
|
||||
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
|
||||
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
|
||||
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
|
||||
- 가능하면 공통 컴포넌트나 위젯 본체를 직접 수정하기보다, 현재 레이아웃에서 필요한 `props`를 내려 동작과 표시를 조정하는 방식으로 구현한다
|
||||
- `Layout Editor 실행` 요청은 기본적으로 "현재 화면 조합을 props/배치/상호작용으로 맞춘다"는 의미로 해석하고, 공통 패키지 내부 구현 변경은 최후 수단으로만 검토한다
|
||||
|
||||
구현 우선순위:
|
||||
|
||||
- 1순위는 기존 컴포넌트/위젯 조합과 `props` 조정만으로 요구사항을 만족시키는 것이다
|
||||
- 2순위는 현재 프로젝트 전용 래퍼, feature 레이어, 어댑터를 추가해 공통 패키지 수정 없이 화면 요구를 흡수하는 것이다
|
||||
- 공통 컴포넌트/위젯 수정이 정말 필요할 때만 기존 사용처를 모두 확인한 뒤 제한적으로 수정한다
|
||||
- 공통 컴포넌트/위젯에 새 동작을 추가할 때는 기본값 `props`를 기존 동작과 동일하게 유지해, 명시적으로 opt-in한 화면만 달라지게 만든다
|
||||
|
||||
금지 해석:
|
||||
|
||||
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
|
||||
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
|
||||
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
|
||||
- 현재 레이아웃 요구를 맞추기 위해 공통 위젯 내부 코드를 바로 덧대고, 그 부작용을 기존 화면이 함께 떠안게 만드는 방식은 지양한다
|
||||
- 기존 화면 영향도 검토 없이 공통 컴포넌트/위젯의 기본 동작, 기본 스타일, 기본 데이터 흐름을 바꾸지 않는다
|
||||
|
||||
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.
|
||||
- 현재 프로젝트 화면에만 의미가 있으면 여기 둡니다.
|
||||
- 공통 재사용 가치가 높아지면 `src/components` 또는 `src/widgets`로 승격합니다.
|
||||
- `Layout Editor`의 기능 명세는 위젯 스펙 문서가 아니라 현재 레이아웃 안에서의 역할 설명으로 취급합니다.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Features Overview
|
||||
|
||||
이 영역은 현재 프로젝트에 종속된 기능과 화면 구성을 관리합니다.
|
||||
`src/features`는 프로젝트 전용 기능 영역입니다.
|
||||
|
||||
## 목적
|
||||
## 구조 기준
|
||||
|
||||
- 공통 `components`, `widgets`와 분리된 프로젝트 전용 기능 관리
|
||||
- 기능별 문서, 화면 조합, 레이아웃을 한 곳에서 정리
|
||||
- 향후 `dashboard`, `sampleBoard`, `docsViewer` 같은 프로젝트 전용 기능 확장
|
||||
- 공통 UI로 분리하기 어려운 화면 로직은 `src/features`에 둡니다.
|
||||
- 재사용 가능한 UI는 `src/components`, 카드형 조합은 `src/widgets`로 분리합니다.
|
||||
- 레이아웃 전용 기능은 `src/features/layout`에서 관리합니다.
|
||||
|
||||
@@ -1160,7 +1160,7 @@ export function PlanBoardPage({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '작업 목록을 불러오지 못했습니다.');
|
||||
setErrorMessage(error instanceof Error ? error.message : '자동화 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -1703,60 +1703,66 @@ export function PlanBoardPage({
|
||||
? currentReleaseUsageSummaryByHistoryId.get(selectedSourceWork.id) ?? null
|
||||
: null;
|
||||
const memoRows = screens.md ? 18 : 9;
|
||||
const isMobileAutomationLayout = !screens.md;
|
||||
const overviewActionContent = (
|
||||
<Space wrap>
|
||||
<Space size={8} className="plan-board-page__auto-refresh-control">
|
||||
<LongPressButton
|
||||
onClick={() => void loadItems(statusFilter)}
|
||||
onLongPress={() => {
|
||||
if (!hasAccess) {
|
||||
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
handleAutoRefreshToggle();
|
||||
}}
|
||||
longPressMs={AUTO_REFRESH_LONG_PRESS_MS}
|
||||
loading={loading}
|
||||
title="길게 눌러 자동조회 On/Off"
|
||||
className={`plan-board-page__auto-refresh-button${
|
||||
isAutoRefreshRunning ? ' plan-board-page__auto-refresh-button--active' : ''
|
||||
}`}
|
||||
>
|
||||
조회
|
||||
</LongPressButton>
|
||||
{isAutoRefreshRunning ? (
|
||||
<Text className="plan-board-page__auto-refresh-countdown">
|
||||
자동 조회까지 {autoRefreshCountdownSeconds}초
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{listRequestMeta ? (
|
||||
<Text type="secondary">
|
||||
최근 조회 {formatResponseBytes(listRequestMeta.responseBytes)} · {listRequestMeta.durationMs}ms
|
||||
</Text>
|
||||
) : null}
|
||||
<Button onClick={handleCreateNew} disabled={isRestrictedClient}>
|
||||
새 메모
|
||||
</Button>
|
||||
<Button onClick={() => void handleSetup()} disabled={isRestrictedClient}>
|
||||
테이블 생성
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
return (
|
||||
<div className="plan-board-page">
|
||||
{contextHolder}
|
||||
|
||||
<Card className="plan-board-page__overview" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<div>
|
||||
<Title level={4}>자동화</Title>
|
||||
<Paragraph className="plan-board-page__intro">
|
||||
작업 메모와 이력, 증적을 확인하고 필요한 내용을 수동으로 정리합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
{isMobileAutomationLayout ? null : (
|
||||
<Card className="plan-board-page__overview" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<div>
|
||||
<Title level={4}>자동화</Title>
|
||||
<Paragraph className="plan-board-page__intro">
|
||||
작업 메모와 이력, 증적을 확인하고 필요한 내용을 수동으로 정리합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Space wrap>
|
||||
<Space size={8} className="plan-board-page__auto-refresh-control">
|
||||
<LongPressButton
|
||||
onClick={() => void loadItems(statusFilter)}
|
||||
onLongPress={() => {
|
||||
if (!hasAccess) {
|
||||
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
handleAutoRefreshToggle();
|
||||
}}
|
||||
longPressMs={AUTO_REFRESH_LONG_PRESS_MS}
|
||||
loading={loading}
|
||||
title="길게 눌러 자동조회 On/Off"
|
||||
className={`plan-board-page__auto-refresh-button${
|
||||
isAutoRefreshRunning ? ' plan-board-page__auto-refresh-button--active' : ''
|
||||
}`}
|
||||
>
|
||||
조회
|
||||
</LongPressButton>
|
||||
{isAutoRefreshRunning ? (
|
||||
<Text className="plan-board-page__auto-refresh-countdown">
|
||||
자동 조회까지 {autoRefreshCountdownSeconds}초
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{listRequestMeta ? (
|
||||
<Text type="secondary">
|
||||
최근 조회 {formatResponseBytes(listRequestMeta.responseBytes)} · {listRequestMeta.durationMs}ms
|
||||
</Text>
|
||||
) : null}
|
||||
<Button onClick={handleCreateNew} disabled={isRestrictedClient}>
|
||||
새 메모
|
||||
</Button>
|
||||
<Button onClick={() => void handleSetup()} disabled={isRestrictedClient}>
|
||||
테이블 생성
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
{overviewActionContent}
|
||||
</Flex>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isRestrictedClient ? (
|
||||
<Alert
|
||||
@@ -1788,7 +1794,7 @@ export function PlanBoardPage({
|
||||
showIcon
|
||||
type="warning"
|
||||
className="plan-board-page__alert"
|
||||
message="작업 요청 메뉴를 아직 사용할 수 없습니다."
|
||||
message="자동화 현황 메뉴를 아직 사용할 수 없습니다."
|
||||
description={<ExpandableDetailText text={errorMessage} />}
|
||||
action={
|
||||
<Button
|
||||
@@ -1804,107 +1810,128 @@ export function PlanBoardPage({
|
||||
) : null}
|
||||
|
||||
<PlanListDetailLayout
|
||||
listTitle={`작업 목록 / ${getPlanQuickFilterLabel(quickFilter) ?? getPlanFilterLabel(statusFilter)}`}
|
||||
listTitle={`자동화 목록 / ${getPlanQuickFilterLabel(quickFilter) ?? getPlanFilterLabel(statusFilter)}`}
|
||||
listExtra={<Text code>{filteredItems.length} items</Text>}
|
||||
listContent={
|
||||
<>
|
||||
{quickFilter ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={quickFilter === 'automation-failed' ? 'error' : 'info'}
|
||||
className="plan-board-page__alert"
|
||||
message={`${getPlanQuickFilterLabel(quickFilter)} 목록을 표시합니다.`}
|
||||
description={
|
||||
quickFilter === 'automation-failed'
|
||||
? '브랜치 생성, 작업, release/main 반영 단계에서 실패한 항목만 추렸습니다.'
|
||||
: quickFilter === 'working'
|
||||
? '현재 상태가 작업중인 항목만 추렸습니다.'
|
||||
: 'release 반영은 끝났지만 main 반영이 남아 있거나 실패한 항목만 추렸습니다.'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{selectedItem?.lastError ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
className="plan-board-page__alert"
|
||||
message="현재 선택된 작업에 오류가 있습니다."
|
||||
description={<ExpandableDetailText text={resolveProtectedText(selectedItem.lastError, !hasAccess)} type="danger" />}
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="오류 메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => void handleCopyText(selectedItem.lastError ?? '')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="plan-board-page__list-panel">
|
||||
<div className="plan-board-page__list-controls">
|
||||
{isMobileAutomationLayout ? (
|
||||
<div className="plan-board-page__mobile-overview">
|
||||
<Flex vertical gap={10}>
|
||||
<div>
|
||||
<Text strong className="plan-board-page__mobile-overview-title">
|
||||
자동화 목록
|
||||
</Text>
|
||||
<Paragraph className="plan-board-page__mobile-overview-description">
|
||||
작업 메모와 이력, 증적을 한 화면 흐름에서 바로 확인합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
{overviewActionContent}
|
||||
</Flex>
|
||||
</div>
|
||||
) : null}
|
||||
{quickFilter ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={quickFilter === 'automation-failed' ? 'error' : 'info'}
|
||||
className="plan-board-page__alert"
|
||||
message={`${getPlanQuickFilterLabel(quickFilter)} 목록을 표시합니다.`}
|
||||
description={
|
||||
quickFilter === 'automation-failed'
|
||||
? '브랜치 생성, 작업, release/main 반영 단계에서 실패한 항목만 추렸습니다.'
|
||||
: quickFilter === 'working'
|
||||
? '현재 상태가 작업중인 항목만 추렸습니다.'
|
||||
: 'release 반영은 끝났지만 main 반영이 남아 있거나 실패한 항목만 추렸습니다.'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{selectedItem?.lastError ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
className="plan-board-page__alert"
|
||||
message="현재 선택된 자동화 항목에 오류가 있습니다."
|
||||
description={
|
||||
<ExpandableDetailText text={resolveProtectedText(selectedItem.lastError, !hasAccess)} type="danger" />
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="오류 메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => void handleCopyText(selectedItem.lastError ?? '')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
placeholder="작업 ID, 메모 내용, 브랜치, 이슈 태그 검색"
|
||||
disabled={isRestrictedClient}
|
||||
onChange={(event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<Flex gap={8} wrap className="plan-board-page__list-filter-bar">
|
||||
<Select
|
||||
size="small"
|
||||
value={workerStateFilter}
|
||||
options={WORKER_STATE_FILTER_OPTIONS}
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
placeholder="작업 ID, 메모 내용, 브랜치, 이슈 태그 검색"
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setWorkerStateFilter}
|
||||
onChange={(event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={releaseStateFilter}
|
||||
options={RELEASE_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setReleaseStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={mainStateFilter}
|
||||
options={MAIN_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setMainStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={issueStateFilter}
|
||||
options={ISSUE_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setIssueStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={costStateFilter}
|
||||
options={COST_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setCostStateFilter}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex gap={8} wrap className="plan-board-page__list-filter-bar">
|
||||
<Select
|
||||
size="small"
|
||||
value={workerStateFilter}
|
||||
options={WORKER_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setWorkerStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={releaseStateFilter}
|
||||
options={RELEASE_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setReleaseStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={mainStateFilter}
|
||||
options={MAIN_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setMainStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={issueStateFilter}
|
||||
options={ISSUE_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setIssueStateFilter}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={costStateFilter}
|
||||
options={COST_STATE_FILTER_OPTIONS}
|
||||
disabled={isRestrictedClient}
|
||||
onChange={setCostStateFilter}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<PlanItemList
|
||||
activeDraftId={draft.id}
|
||||
currentPage={currentListPage}
|
||||
editorOpen={editorOpen}
|
||||
hasAccess={hasAccess}
|
||||
items={filteredItems}
|
||||
jangsingProcessingSavingId={jangsingProcessingSavingId}
|
||||
reviewIndicatorsByPlanId={reviewIndicatorsByPlanId}
|
||||
searchKeyword={searchKeyword}
|
||||
usageSummaryByPlanId={usageSummaryByPlanId}
|
||||
onChangePage={setCurrentListPage}
|
||||
onChangeJangsingProcessing={handleJangsingProcessingChange}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
</>
|
||||
<div className="plan-board-page__list-scroller">
|
||||
<PlanItemList
|
||||
activeDraftId={draft.id}
|
||||
currentPage={currentListPage}
|
||||
editorOpen={editorOpen}
|
||||
hasAccess={hasAccess}
|
||||
items={filteredItems}
|
||||
jangsingProcessingSavingId={jangsingProcessingSavingId}
|
||||
reviewIndicatorsByPlanId={reviewIndicatorsByPlanId}
|
||||
searchKeyword={searchKeyword}
|
||||
usageSummaryByPlanId={usageSummaryByPlanId}
|
||||
onChangePage={setCurrentListPage}
|
||||
onChangeJangsingProcessing={handleJangsingProcessingChange}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
desktopDetailOpen={editorOpen}
|
||||
mobileDetailOpen={editorOpen}
|
||||
@@ -1934,7 +1961,7 @@ export function PlanBoardPage({
|
||||
emptyDetailTitle="상세 보기"
|
||||
detailContent={
|
||||
!sourceViewerOpen ? (
|
||||
<>
|
||||
<div className="plan-board-page__detail-panel">
|
||||
{selectedItem ? (
|
||||
<Alert
|
||||
type={selectedItem.hasOpenIssues ? 'warning' : 'info'}
|
||||
@@ -2488,7 +2515,7 @@ export function PlanBoardPage({
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plan-board-page__overlay-body">
|
||||
<Flex justify="space-between" align="start" gap={12} wrap>
|
||||
|
||||
@@ -103,6 +103,7 @@ export function PlanListDetailLayout({
|
||||
const showMobileDetail = mobileOverlayEnabled && mobileDetailOpen;
|
||||
const showMobileOverlay = showMobileDetail && mobileLayoutMode === 'overlay';
|
||||
const showMobileDetailOnly = showMobileDetail && mobileLayoutMode === 'detail-only';
|
||||
const hideInlineDetailCardOnMobile = mobileOverlayEnabled && (!showMobileDetail || showMobileOverlay);
|
||||
|
||||
useBodyScrollLock(showMobileOverlay || showMobileDetailOnly);
|
||||
|
||||
@@ -118,7 +119,7 @@ export function PlanListDetailLayout({
|
||||
showMobileDetailOnly ? ` ${classNamePrefix}__list-card--mobile-hidden` : ''
|
||||
}`;
|
||||
const detailCardClassName = `${classNamePrefix}__editor-card ${classNamePrefix}__detail-card${
|
||||
showMobileOverlay ? ` ${classNamePrefix}__detail-card--mobile-hidden` : ''
|
||||
hideInlineDetailCardOnMobile ? ` ${classNamePrefix}__detail-card--mobile-hidden` : ''
|
||||
}${showMobileDetailOnly ? ` ${classNamePrefix}__detail-card--mobile-only` : ''}`;
|
||||
const detailActionsClassName = `${classNamePrefix}__detail-actions`;
|
||||
const detailEmptyClassName = `${classNamePrefix}__detail-empty`;
|
||||
|
||||
@@ -136,6 +136,25 @@
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview {
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(22, 93, 255, 0.06) 0%, rgba(22, 93, 255, 0.02) 100%),
|
||||
#ffffff;
|
||||
border: 1px solid rgba(22, 93, 255, 0.08);
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview-title.ant-typography {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview-description.ant-typography {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.plan-board-page__list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -148,6 +167,40 @@
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-panel,
|
||||
.plan-board-page__detail-panel {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__list-controls {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-scroller {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-panel {
|
||||
gap: 14px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar {
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
@@ -592,6 +645,12 @@
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.plan-board-page {
|
||||
overflow: auto;
|
||||
overscroll-behavior: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.plan-board-page__split--mobile-detail-only {
|
||||
gap: 0;
|
||||
}
|
||||
@@ -600,6 +659,65 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.plan-board-page__list-card.ant-card {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.plan-board-page__list-card .ant-card-body,
|
||||
.plan-board-page__editor-card .ant-card-body,
|
||||
.plan-board-page__detail-card .ant-card-body {
|
||||
padding-top: 14px;
|
||||
padding-bottom: max(14px, env(safe-area-inset-bottom, 0px));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.plan-board-page__list-controls {
|
||||
position: static;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview .ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__mobile-overview .ant-space-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar {
|
||||
margin: 0;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar .ant-select {
|
||||
min-width: 136px;
|
||||
flex: 1 1 136px;
|
||||
}
|
||||
|
||||
.plan-board-page__list,
|
||||
.plan-board-page__list-scroller {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.plan-board-page__list-scroller {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plan-board-page__detail-panel {
|
||||
gap: 12px;
|
||||
padding-right: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.plan-board-page__list-card--mobile-hidden,
|
||||
.plan-board-page__detail-card--mobile-hidden {
|
||||
display: none;
|
||||
@@ -911,6 +1029,10 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-board-page__list-card .ant-card-head {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.plan-board-page__form > div {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
@@ -941,6 +1063,10 @@
|
||||
padding: 14px 14px max(18px, env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.plan-board-page__list-filter-bar .ant-select {
|
||||
min-width: 128px;
|
||||
}
|
||||
|
||||
.plan-board-page__readonly-field {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -4,16 +4,31 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import { DataStatePanel } from '../../components/dataStatePanel';
|
||||
import { copyText } from '../../app/main/mainChatPanel';
|
||||
import { fetchServerCommands, restartServerCommand } from './api';
|
||||
import type { ServerCommandItem, ServerCommandKey } from './types';
|
||||
import {
|
||||
ServerCommandApiError,
|
||||
fetchServerCommands,
|
||||
fetchServerRestartReservation,
|
||||
restartServerCommand,
|
||||
scheduleServerRestartReservation,
|
||||
} from './api';
|
||||
import type {
|
||||
RestartReservationWorkloadSummary,
|
||||
ServerCommandItem,
|
||||
ServerCommandKey,
|
||||
ServerRestartReservation,
|
||||
ServerRestartReservationAutoFix,
|
||||
ServerRestartReservationWorkItem,
|
||||
} from './types';
|
||||
import './serverCommand.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type RestartErrorInfo = {
|
||||
tone: 'error' | 'warning';
|
||||
title: string;
|
||||
detail: string;
|
||||
missingScriptPath: string | null;
|
||||
canScheduleReservation: boolean;
|
||||
};
|
||||
|
||||
type LastActionInfo = {
|
||||
@@ -83,28 +98,120 @@ function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErro
|
||||
const missingScriptPath = missingScriptMatch[1].trim();
|
||||
|
||||
return {
|
||||
tone: 'error',
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail: `재기동 스크립트 파일이 서버에 없습니다.\n배치 경로: ${missingScriptPath}\n\n원본 오류:\n${detail}`,
|
||||
missingScriptPath,
|
||||
canScheduleReservation: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'error',
|
||||
title: `${targetLabel} 재기동 실패`,
|
||||
detail,
|
||||
missingScriptPath: null,
|
||||
canScheduleReservation: false,
|
||||
};
|
||||
}
|
||||
|
||||
function formatWorkloadSummary(summary: RestartReservationWorkloadSummary | null) {
|
||||
if (!summary) {
|
||||
return '진행 중 작업이 있어 즉시 재기동할 수 없습니다.';
|
||||
}
|
||||
|
||||
return `Codex 실행 ${summary.codexRunningCount}건, Codex 대기 ${summary.codexQueuedCount}건, 자동화 실행 ${summary.automationRunningCount}건, 자동화 대기 ${summary.automationQueuedCount}건이 감지되었습니다.`;
|
||||
}
|
||||
|
||||
function buildRestartReservationInfo(targetLabel: string, summary: RestartReservationWorkloadSummary | null, detail: string) {
|
||||
return {
|
||||
tone: 'warning' as const,
|
||||
title: `${targetLabel} 즉시 재기동 보류`,
|
||||
detail: `${detail}\n\n${formatWorkloadSummary(summary)}\n현재 화면에서는 전체 재기동 예약으로 이어서 처리할 수 있습니다.`,
|
||||
missingScriptPath: null,
|
||||
canScheduleReservation: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReservationStatusTag(reservation: ServerRestartReservation) {
|
||||
switch (reservation.status) {
|
||||
case 'waiting':
|
||||
return <Tag color="gold">대기 중</Tag>;
|
||||
case 'ready':
|
||||
return <Tag color="blue">자동 실행 예정</Tag>;
|
||||
case 'executing':
|
||||
return <Tag color="processing">재기동 실행 중</Tag>;
|
||||
case 'recovering':
|
||||
return <Tag color="purple">Codex 자동 개선 중</Tag>;
|
||||
case 'completed':
|
||||
return <Tag color="success">완료</Tag>;
|
||||
case 'failed':
|
||||
return <Tag color="error">실패</Tag>;
|
||||
case 'cancelled':
|
||||
return <Tag>취소됨</Tag>;
|
||||
default:
|
||||
return <Tag>대기 없음</Tag>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatReservationWorkItemTag(item: ServerRestartReservationWorkItem) {
|
||||
if (item.kind === 'automation') {
|
||||
if (item.status === 'running') {
|
||||
return <Tag color="processing">자동화 실행</Tag>;
|
||||
}
|
||||
if (item.status === 'queued') {
|
||||
return <Tag color="blue">자동화 대기열</Tag>;
|
||||
}
|
||||
return <Tag color="gold">자동화 선행대기</Tag>;
|
||||
}
|
||||
|
||||
if (item.status === 'running') {
|
||||
return <Tag color="processing">Codex 실행</Tag>;
|
||||
}
|
||||
if (item.status === 'queued') {
|
||||
return <Tag color="blue">Codex 대기열</Tag>;
|
||||
}
|
||||
return <Tag color="gold">Codex 대기</Tag>;
|
||||
}
|
||||
|
||||
function resolveAutoFixTone(autoFix: ServerRestartReservationAutoFix) {
|
||||
if (autoFix.status === 'failed') {
|
||||
return 'error' as const;
|
||||
}
|
||||
|
||||
if (autoFix.status === 'completed') {
|
||||
return 'success' as const;
|
||||
}
|
||||
|
||||
return 'info' as const;
|
||||
}
|
||||
|
||||
function formatAutoFixStatusLabel(status: ServerRestartReservationAutoFix['status']) {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return '요청 대기';
|
||||
case 'running':
|
||||
return '개선 실행 중';
|
||||
case 'completed':
|
||||
return '개선 완료';
|
||||
case 'failed':
|
||||
return '개선 실패';
|
||||
default:
|
||||
return '대기 없음';
|
||||
}
|
||||
}
|
||||
|
||||
export function ServerCommandPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [items, setItems] = useState<ServerCommandItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [reservation, setReservation] = useState<ServerRestartReservation | null>(null);
|
||||
const [restartingKey, setRestartingKey] = useState<ServerCommandKey | null>(null);
|
||||
const [restartErrorInfo, setRestartErrorInfo] = useState<RestartErrorInfo | null>(null);
|
||||
const [copyingRestartError, setCopyingRestartError] = useState(false);
|
||||
const [schedulingReservation, setSchedulingReservation] = useState(false);
|
||||
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
||||
test: { output: null, executedAt: '', restartState: 'completed' },
|
||||
rel: { output: null, executedAt: '', restartState: 'completed' },
|
||||
@@ -127,17 +234,59 @@ export function ServerCommandPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadReservation = async (options?: { silent?: boolean }) => {
|
||||
try {
|
||||
const nextReservation = await fetchServerRestartReservation();
|
||||
setReservation(nextReservation);
|
||||
return nextReservation;
|
||||
} catch (error) {
|
||||
if (!options?.silent) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '재기동 예약 상태를 불러오지 못했습니다.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
setErrorMessage(null);
|
||||
setReservation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadItems();
|
||||
void Promise.all([
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}, [hasAccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPoll =
|
||||
reservation?.enabled
|
||||
|| reservation?.status === 'recovering'
|
||||
|| reservation?.autoFix.enabled
|
||||
|| restartingKey === 'test'
|
||||
|| restartingKey === 'work-server';
|
||||
|
||||
if (!shouldPoll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setInterval(() => {
|
||||
void loadReservation({ silent: true });
|
||||
}, 4000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timerId);
|
||||
};
|
||||
}, [hasAccess, reservation, restartingKey]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return items.reduce(
|
||||
(result, item) => {
|
||||
@@ -156,6 +305,7 @@ export function ServerCommandPage() {
|
||||
try {
|
||||
const result = await restartServerCommand(key);
|
||||
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
|
||||
void loadReservation({ silent: true });
|
||||
setLastActionByKey((previous) => ({
|
||||
...previous,
|
||||
[result.item.key]: {
|
||||
@@ -169,6 +319,11 @@ export function ServerCommandPage() {
|
||||
);
|
||||
} catch (error) {
|
||||
const targetLabel = items.find((item) => item.key === key)?.label ?? key.toUpperCase();
|
||||
if (error instanceof ServerCommandApiError && error.status === 409 && (key === 'test' || key === 'work-server')) {
|
||||
setRestartErrorInfo(buildRestartReservationInfo(targetLabel, error.workloadSummary, error.message));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||
setRestartErrorInfo(buildRestartErrorInfo(targetLabel, detail));
|
||||
} finally {
|
||||
@@ -193,6 +348,26 @@ export function ServerCommandPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleReservation = async () => {
|
||||
if (schedulingReservation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSchedulingReservation(true);
|
||||
|
||||
try {
|
||||
await scheduleServerRestartReservation();
|
||||
setRestartErrorInfo(null);
|
||||
await loadReservation({ silent: true });
|
||||
messageApi.success('전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.';
|
||||
setRestartErrorInfo(buildRestartErrorInfo('전체 서버', detail));
|
||||
} finally {
|
||||
setSchedulingReservation(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card className="server-command-page__card" bordered={false}>
|
||||
@@ -229,7 +404,16 @@ export function ServerCommandPage() {
|
||||
</Col>
|
||||
</Row>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadItems()} loading={loading}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
void Promise.all([
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}}
|
||||
loading={loading}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -239,8 +423,8 @@ export function ServerCommandPage() {
|
||||
{restartErrorInfo ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="재기동 에러"
|
||||
type={restartErrorInfo.tone}
|
||||
message={restartErrorInfo.tone === 'warning' ? '재기동 예약 필요' : '재기동 에러'}
|
||||
description={
|
||||
<Space direction="vertical" size={8} className="server-command-page__alert-body">
|
||||
<Text strong>{restartErrorInfo.title}</Text>
|
||||
@@ -253,20 +437,129 @@ export function ServerCommandPage() {
|
||||
</Space>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingRestartError}
|
||||
aria-label="에러 메시지 복사"
|
||||
onClick={() => {
|
||||
void handleCopyRestartError();
|
||||
}}
|
||||
/>
|
||||
<Space size={4}>
|
||||
{restartErrorInfo.canScheduleReservation ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={schedulingReservation}
|
||||
onClick={() => {
|
||||
void handleScheduleReservation();
|
||||
}}
|
||||
>
|
||||
재기동 예약
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingRestartError}
|
||||
aria-label="에러 메시지 복사"
|
||||
onClick={() => {
|
||||
void handleCopyRestartError();
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{reservation && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
|
||||
<Card className="server-command-page__card server-command-page__reservation-card" bordered={false}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
<Title level={5} className="server-command-page__server-title">
|
||||
재기동 예약 상태
|
||||
</Title>
|
||||
{resolveReservationStatusTag(reservation)}
|
||||
</Space>
|
||||
|
||||
<Paragraph className="server-command-page__summary">
|
||||
{reservation.waitingReason?.trim()
|
||||
|| (reservation.status === 'completed'
|
||||
? '예약된 TEST / WORK 서버 재기동이 완료되었습니다.'
|
||||
: '예약 상태를 확인했습니다.')}
|
||||
</Paragraph>
|
||||
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
className="server-command-page__meta"
|
||||
items={[
|
||||
{
|
||||
key: 'requested-at',
|
||||
label: '요청시각',
|
||||
children: formatDateTime(reservation.requestedAt),
|
||||
},
|
||||
{
|
||||
key: 'auto-execute-at',
|
||||
label: '자동실행',
|
||||
children: formatDateTime(reservation.autoExecuteAt),
|
||||
},
|
||||
{
|
||||
key: 'updated-at',
|
||||
label: '마지막 갱신',
|
||||
children: formatDateTime(reservation.updatedAt),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{reservation.workItems.length > 0 ? (
|
||||
<Space direction="vertical" size={8} className="server-command-page__work-list">
|
||||
<Text strong>현재 진행 작업</Text>
|
||||
{reservation.workItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.kind}-${item.requestId ?? item.title}-${index}`}
|
||||
className="server-command-page__work-item"
|
||||
>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
{formatReservationWorkItemTag(item)}
|
||||
<Text strong>{item.title}</Text>
|
||||
</Space>
|
||||
{item.detail ? (
|
||||
<Text type="secondary" className="server-command-page__work-detail">
|
||||
{item.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
{reservation.autoFix.enabled ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={resolveAutoFixTone(reservation.autoFix)}
|
||||
message="Codex 자동 개선"
|
||||
description={
|
||||
<Space direction="vertical" size={4} className="server-command-page__alert-body">
|
||||
<Text strong>
|
||||
{reservation.autoFix.summary?.trim() || '빌드 오류 자동 개선 상태를 추적 중입니다.'}
|
||||
</Text>
|
||||
{reservation.autoFix.detail ? (
|
||||
<span className="server-command-page__alert-text">{reservation.autoFix.detail}</span>
|
||||
) : null}
|
||||
<Text type="secondary">
|
||||
상태: {formatAutoFixStatusLabel(reservation.autoFix.status)}
|
||||
{reservation.autoFix.targetKey ? ` · 대상 ${reservation.autoFix.targetKey.toUpperCase()}` : ''}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{reservation.lastError ? (
|
||||
<Text type="danger" className="server-command-page__preview">
|
||||
{reservation.lastError}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<DataStatePanel state="loading" title="서버 상태를 확인하는 중입니다." />
|
||||
) : errorMessage ? (
|
||||
@@ -275,7 +568,15 @@ export function ServerCommandPage() {
|
||||
title="서버 명령 메뉴를 불러오지 못했습니다."
|
||||
description={errorMessage}
|
||||
actions={
|
||||
<Button type="primary" onClick={() => void loadItems()}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
void Promise.all([
|
||||
loadItems(),
|
||||
loadReservation({ silent: true }),
|
||||
]);
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken, isAllowedRegistrationToken } from '../../app/main/tokenAccess';
|
||||
import type {
|
||||
RestartReservationWorkloadSummary,
|
||||
ServerCommandActionResult,
|
||||
ServerCommandItem,
|
||||
ServerCommandKey,
|
||||
ServerRestartReservationAutoFix,
|
||||
ServerRestartReservation,
|
||||
ServerRestartReservationStatus,
|
||||
ServerRestartReservationWorkItem,
|
||||
} from './types';
|
||||
|
||||
class ServerCommandApiError extends Error {
|
||||
export class ServerCommandApiError extends Error {
|
||||
status: number;
|
||||
workloadSummary: RestartReservationWorkloadSummary | null;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
constructor(message: string, status: number, workloadSummary: RestartReservationWorkloadSummary | null = null) {
|
||||
super(message);
|
||||
this.name = 'ServerCommandApiError';
|
||||
this.status = status;
|
||||
this.workloadSummary = workloadSummary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,13 +136,30 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let payload: { message?: string; workloadSummary?: Partial<RestartReservationWorkloadSummary> } | null = null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new ServerCommandApiError(payload.message || '서버 명령 요청에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new ServerCommandApiError(text || '서버 명령 요청에 실패했습니다.', response.status);
|
||||
payload = JSON.parse(text) as { message?: string; workloadSummary?: Partial<RestartReservationWorkloadSummary> };
|
||||
} catch {}
|
||||
|
||||
if (payload) {
|
||||
const workloadSummary =
|
||||
payload.workloadSummary && typeof payload.workloadSummary === 'object'
|
||||
? {
|
||||
codexRunningCount: Number(payload.workloadSummary.codexRunningCount ?? 0),
|
||||
codexQueuedCount: Number(payload.workloadSummary.codexQueuedCount ?? 0),
|
||||
automationRunningCount: Number(payload.workloadSummary.automationRunningCount ?? 0),
|
||||
automationQueuedCount: Number(payload.workloadSummary.automationQueuedCount ?? 0),
|
||||
}
|
||||
: null;
|
||||
throw new ServerCommandApiError(
|
||||
payload.message || '서버 명령 요청에 실패했습니다.',
|
||||
response.status,
|
||||
workloadSummary,
|
||||
);
|
||||
}
|
||||
|
||||
throw new ServerCommandApiError(text || '서버 명령 요청에 실패했습니다.', response.status);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
@@ -281,6 +303,7 @@ function normalizeServerRestartReservationStatus(value: unknown): ServerRestartR
|
||||
return value === 'waiting'
|
||||
|| value === 'ready'
|
||||
|| value === 'executing'
|
||||
|| value === 'recovering'
|
||||
|| value === 'completed'
|
||||
|| value === 'cancelled'
|
||||
|| value === 'failed'
|
||||
@@ -288,6 +311,83 @@ function normalizeServerRestartReservationStatus(value: unknown): ServerRestartR
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function normalizeServerRestartReservationTarget(value: unknown): ServerRestartReservation['target'] {
|
||||
return value === 'test' || value === 'work-server' ? value : 'all';
|
||||
}
|
||||
|
||||
function normalizeServerRestartReservationWorkItems(value: unknown): ServerRestartReservationWorkItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidate = item as Partial<ServerRestartReservationWorkItem>;
|
||||
const kind = candidate.kind === 'automation' ? 'automation' : candidate.kind === 'codex' ? 'codex' : null;
|
||||
const status =
|
||||
candidate.status === 'running' || candidate.status === 'queued' || candidate.status === 'waiting'
|
||||
? candidate.status
|
||||
: null;
|
||||
const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
|
||||
|
||||
if (!kind || !status || !title) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
kind,
|
||||
status,
|
||||
title,
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
requestId: typeof candidate.requestId === 'string' ? candidate.requestId : null,
|
||||
sessionId: typeof candidate.sessionId === 'string' ? candidate.sessionId : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeServerRestartReservationAutoFix(value: unknown): ServerRestartReservationAutoFix {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
enabled: false,
|
||||
targetKey: null,
|
||||
requestId: null,
|
||||
sessionId: null,
|
||||
status: 'idle',
|
||||
summary: null,
|
||||
detail: null,
|
||||
requestedAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
failedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const candidate = value as Partial<ServerRestartReservationAutoFix>;
|
||||
|
||||
return {
|
||||
enabled: candidate.enabled === true,
|
||||
targetKey: candidate.targetKey === 'test' || candidate.targetKey === 'work-server' ? candidate.targetKey : null,
|
||||
requestId: typeof candidate.requestId === 'string' ? candidate.requestId : null,
|
||||
sessionId: typeof candidate.sessionId === 'string' ? candidate.sessionId : null,
|
||||
status:
|
||||
candidate.status === 'queued'
|
||||
|| candidate.status === 'running'
|
||||
|| candidate.status === 'completed'
|
||||
|| candidate.status === 'failed'
|
||||
? candidate.status
|
||||
: 'idle',
|
||||
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
requestedAt: typeof candidate.requestedAt === 'string' ? candidate.requestedAt : null,
|
||||
startedAt: typeof candidate.startedAt === 'string' ? candidate.startedAt : null,
|
||||
completedAt: typeof candidate.completedAt === 'string' ? candidate.completedAt : null,
|
||||
failedAt: typeof candidate.failedAt === 'string' ? candidate.failedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractServerRestartReservation(response: unknown): ServerRestartReservation {
|
||||
if (!response || typeof response !== 'object') {
|
||||
throw new Error('재기동 예약 응답 형식이 올바르지 않습니다.');
|
||||
@@ -309,12 +409,12 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
|
||||
const reservation = item as Partial<ServerRestartReservation>;
|
||||
const workloadSummary =
|
||||
reservation.workloadSummary && typeof reservation.workloadSummary === 'object'
|
||||
? reservation.workloadSummary
|
||||
? (reservation.workloadSummary as Partial<RestartReservationWorkloadSummary>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
enabled: reservation.enabled === true,
|
||||
target: reservation.target === 'all' ? 'all' : 'all',
|
||||
target: normalizeServerRestartReservationTarget(reservation.target),
|
||||
status: normalizeServerRestartReservationStatus(reservation.status),
|
||||
requestedAt: typeof reservation.requestedAt === 'string' ? reservation.requestedAt : null,
|
||||
requestedByClientId: typeof reservation.requestedByClientId === 'string' ? reservation.requestedByClientId : null,
|
||||
@@ -338,6 +438,8 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
|
||||
autoExecuteAt: typeof reservation.autoExecuteAt === 'string' ? reservation.autoExecuteAt : null,
|
||||
autoExecuteDelaySeconds: Number(reservation.autoExecuteDelaySeconds ?? 10),
|
||||
updatedAt: typeof reservation.updatedAt === 'string' ? reservation.updatedAt : null,
|
||||
workItems: normalizeServerRestartReservationWorkItems(reservation.workItems),
|
||||
autoFix: normalizeServerRestartReservationAutoFix(reservation.autoFix),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.server-command-page__reservation-card {
|
||||
border: 1px solid #d6e4ff;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.server-command-page__server-card {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -107,6 +112,21 @@
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.server-command-page__work-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-command-page__work-item {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.server-command-page__work-detail.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-command-page__meta .ant-descriptions-item-label {
|
||||
width: 104px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export type ServerCommandKey = 'test' | 'rel' | 'prod' | 'work-server' | 'command-runner';
|
||||
|
||||
export type RestartReservationWorkloadSummary = {
|
||||
codexRunningCount: number;
|
||||
codexQueuedCount: number;
|
||||
automationRunningCount: number;
|
||||
automationQueuedCount: number;
|
||||
};
|
||||
|
||||
export type ServerCommandItem = {
|
||||
key: ServerCommandKey;
|
||||
label: string;
|
||||
@@ -44,25 +51,44 @@ export type ServerRestartReservationStatus =
|
||||
| 'waiting'
|
||||
| 'ready'
|
||||
| 'executing'
|
||||
| 'recovering'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'failed';
|
||||
|
||||
export type ServerRestartReservationWorkItem = {
|
||||
kind: 'codex' | 'automation';
|
||||
status: 'running' | 'queued' | 'waiting';
|
||||
title: string;
|
||||
detail: string | null;
|
||||
requestId: string | null;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export type ServerRestartReservationAutoFix = {
|
||||
enabled: boolean;
|
||||
targetKey: 'test' | 'work-server' | null;
|
||||
requestId: string | null;
|
||||
sessionId: string | null;
|
||||
status: 'idle' | 'queued' | 'running' | 'completed' | 'failed';
|
||||
summary: string | null;
|
||||
detail: string | null;
|
||||
requestedAt: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
failedAt: string | null;
|
||||
};
|
||||
|
||||
export type ServerRestartReservation = {
|
||||
enabled: boolean;
|
||||
target: 'all';
|
||||
target: 'all' | 'test' | 'work-server';
|
||||
status: ServerRestartReservationStatus;
|
||||
requestedAt: string | null;
|
||||
requestedByClientId: string | null;
|
||||
lastCheckedAt: string | null;
|
||||
nextCheckAt: string | null;
|
||||
waitingReason: string | null;
|
||||
workloadSummary: {
|
||||
codexRunningCount: number;
|
||||
codexQueuedCount: number;
|
||||
automationRunningCount: number;
|
||||
automationQueuedCount: number;
|
||||
};
|
||||
workloadSummary: RestartReservationWorkloadSummary;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
@@ -73,4 +99,6 @@ export type ServerRestartReservation = {
|
||||
autoExecuteAt: string | null;
|
||||
autoExecuteDelaySeconds: number;
|
||||
updatedAt: string | null;
|
||||
workItems: ServerRestartReservationWorkItem[];
|
||||
autoFix: ServerRestartReservationAutoFix;
|
||||
};
|
||||
|
||||
40
src/sw.js
40
src/sw.js
@@ -7,17 +7,22 @@ import { NavigationRoute, registerRoute } from 'workbox-routing';
|
||||
clientsClaim();
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
const navigationFallbackDenylist = [
|
||||
/^\/api\/chat\/resources(?:\/|$)/,
|
||||
/^\/(?:public\/)?\.codex_chat(?:\/|$)/,
|
||||
];
|
||||
|
||||
const manifest = self.__WB_MANIFEST;
|
||||
|
||||
if (Array.isArray(manifest) && manifest.length > 0) {
|
||||
precacheAndRoute(manifest);
|
||||
const navigationHandler = createHandlerBoundToURL('/index.html');
|
||||
registerRoute(new NavigationRoute(navigationHandler));
|
||||
registerRoute(new NavigationRoute(navigationHandler, { denylist: navigationFallbackDenylist }));
|
||||
} else {
|
||||
registerRoute(
|
||||
new NavigationRoute(({ request }) => {
|
||||
return fetch(request);
|
||||
}),
|
||||
}, { denylist: navigationFallbackDenylist }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,11 +57,6 @@ function isChatNotificationPayload(payload) {
|
||||
);
|
||||
}
|
||||
|
||||
function extractNotificationSessionId(payload) {
|
||||
const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
|
||||
return normalizeNotificationValue(data.sessionId);
|
||||
}
|
||||
|
||||
function isVisibleAppClient(client) {
|
||||
if (!client || typeof client.url !== 'string') {
|
||||
return false;
|
||||
@@ -72,37 +72,13 @@ function isVisibleAppClient(client) {
|
||||
}
|
||||
}
|
||||
|
||||
function isVisibleChatClientForSession(client, sessionId) {
|
||||
if (!isVisibleAppClient(client)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const clientUrl = new URL(client.url);
|
||||
const clientSessionId = normalizeNotificationValue(clientUrl.searchParams.get('sessionId'));
|
||||
const clientPathname = normalizeNotificationValue(clientUrl.pathname);
|
||||
|
||||
return clientPathname === '/chat/live' && clientSessionId === sessionId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSuppressChatNotificationForVisibleApp(payload) {
|
||||
if (!isChatNotificationPayload(payload)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const notificationSessionId = extractNotificationSessionId(payload);
|
||||
|
||||
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) =>
|
||||
clientList.some((client) => {
|
||||
if (notificationSessionId && isVisibleChatClientForSession(client, notificationSessionId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isVisibleAppClient(client);
|
||||
}),
|
||||
clientList.some((client) => isVisibleAppClient(client)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
117
src/utils/clipboard.ts
Normal file
117
src/utils/clipboard.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
type SelectionSnapshot = {
|
||||
activeElement: Element | null;
|
||||
ranges: Range[];
|
||||
};
|
||||
|
||||
function captureSelectionSnapshot(): SelectionSnapshot | null {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const ranges: Range[] = [];
|
||||
|
||||
if (selection) {
|
||||
for (let index = 0; index < selection.rangeCount; index += 1) {
|
||||
ranges.push(selection.getRangeAt(index).cloneRange());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeElement: document.activeElement,
|
||||
ranges,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreSelectionSnapshot(snapshot: SelectionSnapshot | null) {
|
||||
if (!snapshot || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
snapshot.ranges.forEach((range) => selection?.addRange(range));
|
||||
|
||||
const target = snapshot.activeElement;
|
||||
|
||||
if (target instanceof HTMLElement || target instanceof SVGElement) {
|
||||
try {
|
||||
target.focus({ preventScroll: true });
|
||||
} catch {
|
||||
target.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyUsingTextareaFallback(text: string) {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof document === 'undefined' ||
|
||||
typeof document.execCommand !== 'function' ||
|
||||
!document.body
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapshot = captureSelectionSnapshot();
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.tabIndex = -1;
|
||||
textarea.readOnly = true;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.setAttribute('aria-hidden', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '0';
|
||||
textarea.style.left = '0';
|
||||
textarea.style.width = '1px';
|
||||
textarea.style.height = '1px';
|
||||
textarea.style.padding = '0';
|
||||
textarea.style.border = '0';
|
||||
textarea.style.outline = '0';
|
||||
textarea.style.boxShadow = 'none';
|
||||
textarea.style.background = 'transparent';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
textarea.style.fontSize = '16px';
|
||||
textarea.style.whiteSpace = 'pre';
|
||||
textarea.style.userSelect = 'text';
|
||||
textarea.style.webkitUserSelect = 'text';
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
try {
|
||||
textarea.focus({ preventScroll: true });
|
||||
} catch {
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
textarea.select();
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = text.length;
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
return document.execCommand('copy');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
textarea.remove();
|
||||
restoreSelectionSnapshot(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back when the browser exposes the API but rejects the write.
|
||||
}
|
||||
}
|
||||
|
||||
if (copyUsingTextareaFallback(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('브라우저가 클립보드 복사를 차단했습니다.');
|
||||
}
|
||||
@@ -190,7 +190,7 @@ function formatPercent(value: number) {
|
||||
}
|
||||
|
||||
function getSubjectQuestionCount(examId: string, subjectId: string) {
|
||||
return CBT_QUESTIONS.filter((question) => question.examId === examId && question.subjectId === subjectId).length;
|
||||
return CBT_QUESTIONS.filter((question) => question.isActive && question.examId === examId && question.subjectId === subjectId).length;
|
||||
}
|
||||
|
||||
function getSubjectQuestionSetCount(examId: string, subjectId: string) {
|
||||
|
||||
@@ -33,7 +33,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '타당성 검토에서 기술적 타당성을 확인할 때 가장 먼저 보는 관점은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '현 기술 스택과 인력으로 요구 기능을 구현 가능한지 확인하는 것이 기술적 타당성의 핵심입니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['타당성', '분석'],
|
||||
correctRate: 0.71,
|
||||
choices: ['사무실 좌석 배치를 바꾸는 비용', '광고 문구의 완성도', '현재 기술과 인력으로 구현 가능한지 여부', '배경 이미지 해상도'],
|
||||
@@ -105,7 +105,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: 'DevOps 도입의 직접적인 목표로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '개발과 운영 협업을 강화해 배포 속도와 안정성을 함께 높이는 것이 목적입니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['DevOps', '협업'],
|
||||
correctRate: 0.74,
|
||||
choices: ['운영팀을 없앤다', '테스트를 생략한다', '문서를 금지한다', '개발과 운영의 피드백 주기를 단축한다'],
|
||||
@@ -117,7 +117,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '기존 기능 수정 후 주변 기능이 깨지지 않았는지 확인하는 테스트는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '회귀 테스트는 변경 이후 기존 기능의 정상 동작을 다시 확인하는 테스트입니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['테스트', '회귀'],
|
||||
correctRate: 0.77,
|
||||
choices: ['인수 테스트', '회귀 테스트', '알파 테스트', '베타 테스트'],
|
||||
@@ -153,7 +153,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '시맨틱 버저닝에서 `2.4.1`의 마지막 숫자가 증가하는 일반적인 경우는 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '패치 버전은 하위 호환 가능한 버그 수정이 있을 때 증가합니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['배포', '버전관리'],
|
||||
correctRate: 0.72,
|
||||
choices: ['대규모 구조 개편이 있을 때', '하위 호환이 깨지는 변경일 때', '새로운 주요 기능 묶음을 추가할 때', '하위 호환 가능한 버그 수정일 때'],
|
||||
@@ -189,7 +189,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '집계 결과에 조건을 적용할 때 `WHERE` 대신 주로 사용하는 절은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '집계 이후의 그룹 조건은 HAVING 절에서 처리합니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['SQL', '집계'],
|
||||
correctRate: 0.79,
|
||||
choices: ['ORDER BY', 'LIMIT', 'GROUP SETS', 'HAVING'],
|
||||
@@ -201,7 +201,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '공통 키가 있는 행만 결과로 가져오려면 어떤 조인을 사용해야 하나요?',
|
||||
answerValue: '1',
|
||||
explanation: 'INNER JOIN은 양쪽 테이블에서 조건이 일치하는 행만 반환합니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['SQL', '조인'],
|
||||
correctRate: 0.81,
|
||||
choices: ['INNER JOIN', 'LEFT OUTER JOIN', 'CROSS JOIN', 'SELF JOIN'],
|
||||
@@ -237,7 +237,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '재귀 함수가 무한 호출되지 않도록 반드시 갖춰야 하는 요소는 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '재귀 종료 조건(base case)이 있어야 반복 호출이 멈춥니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['기초', '재귀'],
|
||||
correctRate: 0.82,
|
||||
choices: ['종료 조건', '전역 변수', '배열 정렬', 'GUI 이벤트'],
|
||||
@@ -273,7 +273,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '데이터와 메서드를 하나의 단위로 묶고 외부 접근을 제한하는 개념은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '캡슐화는 내부 구현을 숨기고 필요한 인터페이스만 노출합니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['객체지향', '캡슐화'],
|
||||
correctRate: 0.78,
|
||||
choices: ['오버로딩', '추상화', '캡슐화', '가비지 컬렉션'],
|
||||
@@ -285,7 +285,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '선입선출(FIFO) 구조로 동작하는 자료구조는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: 'Queue는 먼저 들어온 데이터가 먼저 나가는 FIFO 구조입니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['자료구조', '큐'],
|
||||
correctRate: 0.8,
|
||||
choices: ['Stack', 'Queue', 'Tree', 'Graph'],
|
||||
@@ -309,7 +309,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '변경관리(Change Management)의 핵심 목적은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '변경 요청을 통제해 서비스 영향과 위험을 줄이려는 것이 핵심입니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['운영', '변경관리'],
|
||||
correctRate: 0.73,
|
||||
choices: ['문서 작성을 금지한다', '모든 변경을 즉시 반영한다', '개발 서버만 유지한다', '변경 영향과 승인 절차를 관리한다'],
|
||||
@@ -333,7 +333,7 @@ export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
body: '최소 권한 원칙(Principle of Least Privilege)의 설명으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '업무 수행에 필요한 최소한의 권한만 부여해야 보안 위험을 줄일 수 있습니다.',
|
||||
difficulty: 'easy',
|
||||
difficulty: 'medium',
|
||||
tags: ['보안', '권한'],
|
||||
correctRate: 0.76,
|
||||
choices: ['필요한 최소 권한만 부여한다', '관리자 권한을 기본값으로 준다', '모든 로그를 삭제한다', '암호를 화면에 표시한다'],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExamCategory, QuestionRecord, QuestionSet, Subject } from './cbtTypes';
|
||||
import { CBT_BONUS_QUESTION_SEEDS } from './cbtBonusQuestionSeeds';
|
||||
import { CBT_SUBJECT_EXPANSION_QUESTION_SEEDS } from './cbtSubjectExpansionSeeds';
|
||||
|
||||
type QuestionSeed = Omit<QuestionRecord, 'choices' | 'sourceLabel' | 'examId' | 'type' | 'isActive'> & {
|
||||
examId?: string;
|
||||
@@ -10,6 +11,7 @@ type QuestionSeed = Omit<QuestionRecord, 'choices' | 'sourceLabel' | 'examId' |
|
||||
const ENGINEER_SOURCE_CORE = '비공식 재구성 문제집';
|
||||
const ENGINEER_SOURCE_PRACTICE = '비공식 실전형 문제집';
|
||||
const WEB_SOURCE = '공개 허용 샘플 형식';
|
||||
const WEB_SOURCE_ADVANCED = '공개 허용 심화 샘플 형식';
|
||||
|
||||
export const CBT_EXAMS: ExamCategory[] = [
|
||||
{ id: 'engineer-info', label: '정보처리기사' },
|
||||
@@ -60,9 +62,26 @@ export const CBT_QUESTION_SETS: QuestionSet[] = [
|
||||
{ id: 'system-security', examId: 'engineer-info', subjectId: 'system', label: '보안/위험 문제집', sourceLabel: ENGINEER_SOURCE_PRACTICE },
|
||||
{ id: 'system-infra', examId: 'engineer-info', subjectId: 'system', label: '인프라/운영 문제집', sourceLabel: ENGINEER_SOURCE_PRACTICE },
|
||||
{ id: 'web-core', examId: 'web-general', subjectId: 'html-css', label: '입문 문제집', sourceLabel: WEB_SOURCE },
|
||||
{ id: 'web-layout', examId: 'web-general', subjectId: 'html-css', label: '레이아웃 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||
{ id: 'web-accessibility', examId: 'web-general', subjectId: 'html-css', label: '접근성 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||
{ id: 'web-responsive', examId: 'web-general', subjectId: 'html-css', label: '반응형 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||
{ id: 'web-performance', examId: 'web-general', subjectId: 'html-css', label: '성능 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||
{ id: 'web-browser', examId: 'web-general', subjectId: 'html-css', label: '브라우저/모바일 문제집', sourceLabel: WEB_SOURCE_ADVANCED },
|
||||
];
|
||||
|
||||
const QUESTION_SET_SOURCE_MAP = new Map(CBT_QUESTION_SETS.map((item) => [item.id, item.sourceLabel]));
|
||||
const INACTIVE_QUESTION_IDS = new Set([
|
||||
'dev-core-2',
|
||||
'dev-core-2-v1',
|
||||
'dev-core-2-v2',
|
||||
'dev-core-2-v3',
|
||||
'programming-core-2',
|
||||
'programming-core-2-v1',
|
||||
'programming-core-2-v2',
|
||||
'programming-core-2-v3',
|
||||
'web-core-1',
|
||||
'web-core-3',
|
||||
]);
|
||||
|
||||
function buildQuestion(seed: QuestionSeed): QuestionRecord {
|
||||
return {
|
||||
@@ -78,7 +97,7 @@ function buildQuestion(seed: QuestionSeed): QuestionRecord {
|
||||
sourceLabel: seed.sourceLabel ?? QUESTION_SET_SOURCE_MAP.get(seed.setId) ?? ENGINEER_SOURCE_PRACTICE,
|
||||
tags: seed.tags,
|
||||
correctRate: seed.correctRate,
|
||||
isActive: true,
|
||||
isActive: !INACTIVE_QUESTION_IDS.has(seed.id),
|
||||
choices: seed.choices.map((label, index) => ({
|
||||
value: String(index + 1),
|
||||
label,
|
||||
@@ -1098,6 +1117,7 @@ export const CBT_QUESTIONS: QuestionRecord[] = [
|
||||
...QUESTION_SEEDS,
|
||||
...ENGINEER_VARIANT_QUESTION_SEEDS,
|
||||
...CBT_BONUS_QUESTION_SEEDS,
|
||||
...CBT_SUBJECT_EXPANSION_QUESTION_SEEDS,
|
||||
].map(buildQuestion);
|
||||
|
||||
export const QUICK_QUESTION_COUNTS = [10, 20, 40, 60, 80];
|
||||
|
||||
655
src/views/play/apps/cbt/cbtSubjectExpansionSeeds.ts
Normal file
655
src/views/play/apps/cbt/cbtSubjectExpansionSeeds.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
type BonusQuestionSeed = {
|
||||
id: string;
|
||||
examId?: string;
|
||||
subjectId: string;
|
||||
setId: string;
|
||||
body: string;
|
||||
answerValue: string;
|
||||
explanation: string;
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
tags: string[];
|
||||
correctRate: number;
|
||||
choices: [string, string, string, string];
|
||||
sourceLabel?: string;
|
||||
};
|
||||
|
||||
export const CBT_SUBJECT_EXPANSION_QUESTION_SEEDS: BonusQuestionSeed[] = [
|
||||
{
|
||||
id: 'algo-core-9',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-core',
|
||||
body: '요구사항 검증 회의에서 모호한 표현을 우선 수정해야 하는 가장 직접적인 이유는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '모호한 요구사항은 구현과 테스트 기준을 흐리게 만들어 해석 차이를 키웁니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['요구사항', '검증'],
|
||||
correctRate: 0.62,
|
||||
choices: ['화면 수를 줄이기 위해', '구현자마다 다른 해석이 생기지 않게 하려고', '배포 속도를 높이기 위해', 'DB 정규화를 생략하려고'],
|
||||
},
|
||||
{
|
||||
id: 'algo-core-10',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-core',
|
||||
body: '현행 업무 분석에서 AS-IS와 TO-BE를 함께 정리하는 이유로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '현재 상태와 목표 상태를 나란히 봐야 개선 범위와 변경 포인트를 명확히 잡을 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['현행분석', '요구사항'],
|
||||
correctRate: 0.58,
|
||||
choices: ['소스 코드를 자동 생성하려고', '운영 서버를 줄이려고', '테스트를 생략하려고', '개선 대상과 전환 범위를 비교하려고'],
|
||||
},
|
||||
{
|
||||
id: 'algo-pattern-9',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-pattern',
|
||||
body: '객체 생성 절차가 복잡하고 생성 단계 조합이 많을 때 적용하기 좋은 패턴은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: 'Builder 패턴은 복잡한 생성 절차를 단계별로 분리해 조합하기 좋습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['패턴', '생성'],
|
||||
correctRate: 0.56,
|
||||
choices: ['State', 'Facade', 'Builder', 'Observer'],
|
||||
},
|
||||
{
|
||||
id: 'algo-quality-9',
|
||||
subjectId: 'algo',
|
||||
setId: 'algo-quality',
|
||||
body: '마이크로서비스 간 결합이 과도할 때 주로 나타나는 문제로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '서비스 간 의존이 강하면 한 서비스 변경이 연쇄 장애나 배포 제약으로 이어집니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['아키텍처', '품질'],
|
||||
correctRate: 0.43,
|
||||
choices: ['하나의 변경이 여러 서비스 배포를 동시에 요구한다', '정적 파일 캐시 효율이 높아진다', '데이터 모델이 항상 단순해진다', '문서 작성량이 자동 감소한다'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dev-core-9',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-core',
|
||||
body: '점증적(Incremental) 개발 모델을 적용할 때 기대 효과로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '작은 단위로 기능을 나눠 제공하면 사용자 피드백을 더 빠르게 반영할 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['개발방법론', '점증적'],
|
||||
correctRate: 0.6,
|
||||
choices: ['모든 기능을 마지막에만 검증한다', '우선순위 높은 기능부터 점진적으로 제공한다', '요구사항 변경을 금지한다', '배포를 한 번만 수행한다'],
|
||||
},
|
||||
{
|
||||
id: 'dev-core-10',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-core',
|
||||
body: '사용자 스토리(User Story)에 수용 기준(Acceptance Criteria)을 함께 적는 이유는 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '수용 기준이 있어야 완료 판단 기준과 테스트 관점을 구체화할 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['애자일', '요구사항'],
|
||||
correctRate: 0.64,
|
||||
choices: ['회의 시간을 늘리려고', '디자인 시안을 줄이려고', '완료 조건과 검증 기준을 명확히 하려고', '소스 저장소를 분리하려고'],
|
||||
},
|
||||
{
|
||||
id: 'dev-test-9',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-test',
|
||||
body: '동등 분할 기법의 주된 목적은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '유사하게 동작할 것으로 기대되는 입력 그룹을 나눠 대표값으로 효율적으로 테스트합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['테스트', '블랙박스'],
|
||||
correctRate: 0.63,
|
||||
choices: ['유사 입력군을 대표값으로 묶어 테스트 효율을 높인다', '코드 실행 경로를 모두 시각화한다', '배포 파이프라인을 생략한다', '운영 로그를 삭제한다'],
|
||||
},
|
||||
{
|
||||
id: 'dev-test-10',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-test',
|
||||
body: '상태 전이 테스트가 특히 유용한 시스템 사례로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '로그인 잠금, 주문 상태 전환처럼 상태와 이벤트 조합이 많은 기능에서 효과적입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['테스트', '상태전이'],
|
||||
correctRate: 0.57,
|
||||
choices: ['정적 소개 페이지', '이미지 파일 압축 작업', '컬러 팔레트 정리', '주문 상태가 단계별로 바뀌는 업무 흐름'],
|
||||
},
|
||||
{
|
||||
id: 'dev-release-9',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-release',
|
||||
body: '지속적 전달(CD) 환경에서 배포 승인을 별도 단계로 두는 이유는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '자동 검증을 통과해도 운영 반영 시점과 위험도를 사람이 최종 판단할 수 있어야 합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['배포', 'CD'],
|
||||
correctRate: 0.59,
|
||||
choices: ['브랜치 이름을 짧게 만들려고', '운영 반영 타이밍과 위험을 통제하려고', '테스트 데이터를 삭제하려고', '형상 관리를 중단하려고'],
|
||||
},
|
||||
{
|
||||
id: 'dev-release-10',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-release',
|
||||
body: '릴리즈 전 체크리스트에 데이터 마이그레이션 검증을 넣어야 하는 이유는 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '스키마나 데이터 구조 변경은 기능 이상보다 더 치명적인 운영 장애로 이어질 수 있습니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['배포', '데이터'],
|
||||
correctRate: 0.46,
|
||||
choices: ['운영 데이터 손상과 롤백 위험을 줄이기 위해', '화면 색상을 맞추기 위해', '개발자 수를 줄이기 위해', '문서 버전을 숨기기 위해'],
|
||||
},
|
||||
{
|
||||
id: 'dev-release-11',
|
||||
subjectId: 'dev',
|
||||
setId: 'dev-release',
|
||||
body: '형상 식별(Configuration Identification)의 설명으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '어떤 산출물을 관리 대상으로 삼고 버전과 구성 단위를 구분할지 정하는 단계입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['형상관리', '구성관리'],
|
||||
correctRate: 0.55,
|
||||
choices: ['모든 문서를 폐기하는 절차', '배포 후 로그만 남기는 절차', '관리 대상 산출물과 버전 기준을 정의하는 활동', '테스트를 운영에서만 수행하는 방식'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'db-core-9',
|
||||
subjectId: 'db',
|
||||
setId: 'db-core',
|
||||
body: '후보키(Candidate Key)의 설명으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '후보키는 유일성과 최소성을 만족하는 키 후보이며 이 중 하나가 기본키가 됩니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['키', '모델링'],
|
||||
correctRate: 0.6,
|
||||
choices: ['반드시 외래키를 포함하는 키', '정렬용으로만 쓰는 컬럼', 'NULL이 허용되는 식별자', '유일성과 최소성을 만족하는 식별자 후보'],
|
||||
},
|
||||
{
|
||||
id: 'db-core-10',
|
||||
subjectId: 'db',
|
||||
setId: 'db-core',
|
||||
body: '이행 함수 종속을 제거하는 정규화 단계는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '제3정규형(3NF)은 이행 함수 종속을 제거하는 단계입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['정규화', '모델링'],
|
||||
correctRate: 0.57,
|
||||
choices: ['제1정규형', '제3정규형', 'BCNF', '제5정규형'],
|
||||
},
|
||||
{
|
||||
id: 'db-sql-9',
|
||||
subjectId: 'db',
|
||||
setId: 'db-sql',
|
||||
body: '윈도우 함수 `ROW_NUMBER()`를 주로 사용하는 상황으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '그룹 내 순번 계산이나 상위 N건 추출처럼 행 순서를 함께 다뤄야 할 때 유용합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['SQL', '윈도우함수'],
|
||||
correctRate: 0.54,
|
||||
choices: ['정렬 기준에 따라 행 순번을 계산할 때', '테이블을 삭제할 때', '인덱스를 비활성화할 때', '트랜잭션을 커밋할 때'],
|
||||
},
|
||||
{
|
||||
id: 'db-sql-10',
|
||||
subjectId: 'db',
|
||||
setId: 'db-sql',
|
||||
body: '실행 계획을 확인하는 가장 직접적인 이유는 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '실제 접근 경로와 비용이 어떻게 계산됐는지 봐야 느린 쿼리의 원인을 찾을 수 있습니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['SQL', '튜닝'],
|
||||
correctRate: 0.42,
|
||||
choices: ['UI 레이아웃을 조정하려고', '샘플 데이터를 지우려고', '조회 경로와 병목 구간을 분석하려고', '백업 주기를 바꾸려고'],
|
||||
},
|
||||
{
|
||||
id: 'db-ops-9',
|
||||
subjectId: 'db',
|
||||
setId: 'db-ops',
|
||||
body: 'Repeatable Read 격리 수준이 주로 방지하려는 현상은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '같은 행을 두 번 읽을 때 값이 바뀌는 비반복 읽기 현상을 막는 데 초점이 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['트랜잭션', '격리수준'],
|
||||
correctRate: 0.53,
|
||||
choices: ['Dirty Write', 'Non-repeatable Read', 'Hash Collision', 'Full Scan'],
|
||||
},
|
||||
{
|
||||
id: 'db-ops-10',
|
||||
subjectId: 'db',
|
||||
setId: 'db-ops',
|
||||
body: '포인트 인 타임 복구(PITR)가 필요한 상황으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '잘못된 대량 삭제처럼 특정 시점 이전으로 복구해야 할 때 PITR이 유용합니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['백업', '복구'],
|
||||
correctRate: 0.45,
|
||||
choices: ['화면 정렬 순서를 바꿀 때', '정적 파일을 압축할 때', '새 테마를 적용할 때', '특정 시점 직전 상태로 데이터를 되돌려야 할 때'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'programming-core-9',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-core',
|
||||
body: '포인터 연산을 잘못 사용했을 때 발생하기 쉬운 문제로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '잘못된 주소 접근은 메모리 오류와 비정상 종료를 유발할 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['C', '포인터'],
|
||||
correctRate: 0.57,
|
||||
choices: ['의도하지 않은 메모리 영역 접근', '컴파일러 업데이트 자동 수행', '정렬 알고리즘 단순화', '함수 수 감소'],
|
||||
},
|
||||
{
|
||||
id: 'programming-core-10',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-core',
|
||||
body: 'Call by Reference 방식의 특징으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '참조를 전달하므로 함수 내부 변경이 원본 데이터에 반영될 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['기초', '함수'],
|
||||
correctRate: 0.63,
|
||||
choices: ['항상 복사본만 바뀐다', '지역 변수를 만들 수 없다', '원본 데이터가 직접 수정될 수 있다', '배열을 전달할 수 없다'],
|
||||
},
|
||||
{
|
||||
id: 'programming-oo-9',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-oo',
|
||||
body: '인터페이스를 우선 설계하는 이유로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '구현보다 계약을 먼저 고정하면 교체 가능성과 테스트 용이성이 높아집니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['객체지향', '인터페이스'],
|
||||
correctRate: 0.61,
|
||||
choices: ['상속 구조를 없애려고', '구현체 교체와 의존성 분리를 쉽게 하려고', '예외 처리를 금지하려고', '모든 메서드를 static으로 만들려고'],
|
||||
},
|
||||
{
|
||||
id: 'programming-oo-10',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-oo',
|
||||
body: '추상화가 잘된 설계의 효과로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '핵심 개념과 계약만 드러내고 세부 구현을 숨겨 변경 영향을 줄일 수 있습니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['객체지향', '추상화'],
|
||||
correctRate: 0.45,
|
||||
choices: ['모든 필드를 public으로 노출한다', '런타임 비용을 항상 0으로 만든다', '소스 파일 수를 절반으로 줄인다', '세부 구현 변경이 외부 사용처에 덜 번지게 한다'],
|
||||
},
|
||||
{
|
||||
id: 'programming-script-9',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-script',
|
||||
body: '이진 탐색의 전제 조건으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '이진 탐색은 탐색 대상이 정렬돼 있어야 절반씩 범위를 줄일 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['알고리즘', '탐색'],
|
||||
correctRate: 0.62,
|
||||
choices: ['데이터가 정렬돼 있어야 한다', '항상 연결 리스트여야 한다', '재귀를 사용할 수 없다', '중복 데이터가 없어야만 한다'],
|
||||
},
|
||||
{
|
||||
id: 'programming-script-10',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-script',
|
||||
body: '깊이 우선 탐색(DFS)에 대한 설명으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '가능한 한 깊게 내려간 뒤 더 갈 곳이 없으면 되돌아오는 탐색입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['알고리즘', '그래프'],
|
||||
correctRate: 0.6,
|
||||
choices: ['항상 최단 경로를 보장한다', '한 경로를 끝까지 탐색한 뒤 되돌아온다', '큐만 사용해야 한다', '정렬된 배열에서만 동작한다'],
|
||||
},
|
||||
{
|
||||
id: 'programming-script-11',
|
||||
subjectId: 'programming',
|
||||
setId: 'programming-script',
|
||||
body: '해시 함수 품질이 낮을 때 생기기 쉬운 문제로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '충돌이 많아지면 평균 접근 성능이 떨어지고 해시 테이블 장점이 약해집니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['자료구조', '해시'],
|
||||
correctRate: 0.44,
|
||||
choices: ['정렬이 자동으로 보장된다', '메모리 사용량이 0이 된다', '재귀 호출이 불가능해진다', '충돌 증가로 탐색 성능이 저하된다'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'system-core-9',
|
||||
subjectId: 'system',
|
||||
setId: 'system-core',
|
||||
body: '리스크 등록부(Risk Register)에 우선 포함해야 할 항목으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '위험 내용, 영향도, 대응 계획, 담당자 같은 정보가 있어야 관리가 가능합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['위험관리', '프로젝트관리'],
|
||||
correctRate: 0.61,
|
||||
choices: ['디자인 시안 배경색', '팀 점심 메뉴', '위험 항목과 대응 전략', '브라우저 북마크 목록'],
|
||||
},
|
||||
{
|
||||
id: 'system-core-10',
|
||||
subjectId: 'system',
|
||||
setId: 'system-core',
|
||||
body: '변경 영향도 분석을 생략했을 때 발생하기 쉬운 문제는 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '연관 시스템과 운영 절차를 빠뜨리면 배포 후 예상치 못한 장애가 발생하기 쉽습니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['변경관리', '운영'],
|
||||
correctRate: 0.47,
|
||||
choices: ['연쇄 영향 범위를 놓쳐 장애를 키울 수 있다', '정규화 단계가 자동 감소한다', '테스트 케이스가 자동 작성된다', '모든 승인 절차가 단축된다'],
|
||||
},
|
||||
{
|
||||
id: 'system-security-9',
|
||||
subjectId: 'system',
|
||||
setId: 'system-security',
|
||||
body: '다중 인증(MFA)의 직접적인 효과로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '비밀번호가 노출돼도 추가 인증 수단이 있어 계정 탈취 위험을 낮출 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['보안', '인증'],
|
||||
correctRate: 0.67,
|
||||
choices: ['세션 저장소를 없앤다', '단일 인증 정보 유출만으로는 로그인하기 어렵게 만든다', '암호화를 대체한다', '방화벽 설정을 불필요하게 만든다'],
|
||||
},
|
||||
{
|
||||
id: 'system-security-10',
|
||||
subjectId: 'system',
|
||||
setId: 'system-security',
|
||||
body: '입력값 검증이 특히 중요한 이유로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '입력 검증은 SQL Injection, XSS 같은 공격 표면을 줄이는 기본 통제입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['보안', '입력검증'],
|
||||
correctRate: 0.64,
|
||||
choices: ['서버 시간을 고정하려고', '디자인 토큰을 통일하려고', '문서 용량을 줄이려고', '악의적 입력으로 인한 취약점 노출을 막으려고'],
|
||||
},
|
||||
{
|
||||
id: 'system-infra-9',
|
||||
subjectId: 'system',
|
||||
setId: 'system-infra',
|
||||
body: '관측 가능성(Observability)을 높이는 데 필요한 축으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '로그, 메트릭, 트레이스를 함께 봐야 분산 환경의 원인 분석이 쉬워집니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['인프라', '관측성'],
|
||||
correctRate: 0.57,
|
||||
choices: ['테마, 배경, 아이콘', '문서, 회의록, 공지', '로그, 메트릭, 트레이스', 'CPU, 키보드, 마우스'],
|
||||
},
|
||||
{
|
||||
id: 'system-infra-10',
|
||||
subjectId: 'system',
|
||||
setId: 'system-infra',
|
||||
body: '무중단 배포를 설계할 때 세션 저장소 외부화가 중요한 이유는 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '인스턴스 교체 시 세션이 서버 로컬에 있으면 사용자 연결 상태가 쉽게 끊길 수 있습니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['인프라', '배포'],
|
||||
correctRate: 0.46,
|
||||
choices: ['서버 교체 중에도 사용자 세션을 유지하기 쉬워진다', '정규화 단계를 줄일 수 있다', '모든 장애를 예방한다', '쿼리 튜닝이 자동 완료된다'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'web-layout-1',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-layout',
|
||||
body: 'Flex 레이아웃에서 세로 축 정렬을 제어하는 속성으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '기본 방향이 row일 때 교차 축은 세로 방향이며 `align-items`가 이를 제어합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['CSS', 'Flexbox'],
|
||||
correctRate: 0.66,
|
||||
choices: ['justify-content', 'align-items', 'flex-basis', 'order'],
|
||||
},
|
||||
{
|
||||
id: 'web-layout-2',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-layout',
|
||||
body: 'CSS Grid에서 열 반복 구성을 정의할 때 가장 자주 사용하는 속성은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '`grid-template-columns`로 열 개수와 너비 패턴을 지정합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['CSS', 'Grid'],
|
||||
correctRate: 0.62,
|
||||
choices: ['grid-auto-flow', 'place-content', 'grid-template-columns', 'grid-column-end'],
|
||||
},
|
||||
{
|
||||
id: 'web-layout-3',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-layout',
|
||||
body: '모바일 카드 UI에서 긴 텍스트가 레이아웃을 깨지 않게 처리하는 방법으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '줄 수 제한이나 줄바꿈 정책을 명확히 둬야 작은 화면에서도 카드 높이 폭주를 막을 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['CSS', '모바일'],
|
||||
correctRate: 0.64,
|
||||
choices: ['모든 텍스트를 절대 위치로 바꾼다', '폰트 크기를 8px로 고정한다', '스크롤을 막는다', 'line-clamp 또는 적절한 word-break 규칙을 적용한다'],
|
||||
},
|
||||
{
|
||||
id: 'web-layout-4',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-layout',
|
||||
body: '`position: sticky`가 기대대로 동작하지 않는 대표 원인으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '상위 스크롤 컨테이너와 overflow 조건이 맞지 않으면 sticky 기준이 깨집니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['CSS', '레이아웃'],
|
||||
correctRate: 0.45,
|
||||
choices: ['부모의 overflow 조건 때문에 기준 스크롤 영역이 달라진다', 'HTML에 section 태그가 없기 때문이다', '이미지 개수가 많기 때문이다', 'font-weight가 bold이기 때문이다'],
|
||||
},
|
||||
{
|
||||
id: 'web-accessibility-1',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-accessibility',
|
||||
body: '폼 입력과 라벨을 명시적으로 연결하는 가장 기본적인 방법은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '`label`의 `for`와 입력 요소의 `id`를 연결하면 스크린리더와 클릭 영역 모두 개선됩니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['HTML', '접근성'],
|
||||
correctRate: 0.71,
|
||||
choices: ['placeholder만 넣는다', '`label for`와 `input id`를 연결한다', 'div로만 감싼다', '색상 대비만 높인다'],
|
||||
},
|
||||
{
|
||||
id: 'web-accessibility-2',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-accessibility',
|
||||
body: '장식용 이미지를 스크린리더가 무시하게 하는 방법으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '의미 없는 장식 이미지는 빈 대체 텍스트로 처리해 불필요한 읽기를 막습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['HTML', '접근성'],
|
||||
correctRate: 0.67,
|
||||
choices: ['title만 넣는다', 'role을 article로 준다', 'alt=\"\"로 둔다', 'font-size를 줄인다'],
|
||||
},
|
||||
{
|
||||
id: 'web-accessibility-3',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-accessibility',
|
||||
body: '모달이 열렸을 때 키보드 포커스를 모달 내부에 가둬야 하는 이유는 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '배경 UI로 포커스가 빠지면 키보드 사용자와 보조기기 사용자의 맥락이 무너집니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['접근성', 'UI'],
|
||||
correctRate: 0.48,
|
||||
choices: ['애니메이션 속도를 올리려고', '스크롤을 완전히 막으려고', '이미지 로딩을 줄이려고', '활성 대화 상자의 상호작용 범위를 명확히 유지하려고'],
|
||||
},
|
||||
{
|
||||
id: 'web-accessibility-4',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-accessibility',
|
||||
body: '버튼처럼 동작하는 요소를 `div` 대신 `button`으로 만드는 주된 이유는 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '기본 키보드 동작, 포커스, 접근성 의미가 이미 제공되기 때문입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['HTML', '접근성'],
|
||||
correctRate: 0.73,
|
||||
choices: ['기본 상호작용 의미와 키보드 지원을 바로 얻을 수 있어서', 'CSS 파일 개수를 줄여서', '이미지 최적화를 위해서', '로컬스토리지를 쓰기 위해서'],
|
||||
},
|
||||
{
|
||||
id: 'web-accessibility-5',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-accessibility',
|
||||
body: '색상만으로 오류 상태를 표시하면 안 되는 이유로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '색각 이상 사용자나 보조기기 사용자에게 정보가 전달되지 않을 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['접근성', '폼'],
|
||||
correctRate: 0.69,
|
||||
choices: ['브라우저가 자동 종료되기 때문에', '색상을 구분하지 못하는 사용자에게 의미가 전달되지 않을 수 있어서', 'HTML 파서 오류가 발생해서', '네트워크 요청이 느려져서'],
|
||||
},
|
||||
{
|
||||
id: 'web-responsive-1',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-responsive',
|
||||
body: '반응형 웹에서 `meta viewport` 설정이 필요한 가장 직접적인 이유는 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '모바일 브라우저가 레이아웃 폭을 기기 너비 기준으로 해석하게 해야 의도한 반응형이 동작합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['HTML', '반응형'],
|
||||
correctRate: 0.68,
|
||||
choices: ['캐시를 비우기 위해', '애니메이션을 끄기 위해', '기기 너비 기준 초기 배율과 레이아웃 폭을 맞추기 위해', '쿠키를 막기 위해'],
|
||||
},
|
||||
{
|
||||
id: 'web-responsive-2',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-responsive',
|
||||
body: '작은 화면에서 카드가 너무 촘촘할 때 우선 검토할 CSS 전략으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '간격, 컬럼 수, 최소 너비를 브레이크포인트별로 조정해야 모바일 가독성이 확보됩니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['CSS', '반응형'],
|
||||
correctRate: 0.61,
|
||||
choices: ['모든 카드 높이를 고정한다', '이미지를 삭제한다', '폰트를 임의로 랜덤 변경한다', '브레이크포인트에서 gap과 컬럼 구성을 줄인다'],
|
||||
},
|
||||
{
|
||||
id: 'web-responsive-3',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-responsive',
|
||||
body: '`clamp()` 함수를 타이포그래피에 사용하는 장점으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '최소값과 최대값을 두고 뷰포트에 따라 유연하게 크기를 조절할 수 있습니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['CSS', '타이포그래피'],
|
||||
correctRate: 0.47,
|
||||
choices: ['너무 작거나 큰 값을 막으면서 유동 크기를 줄 수 있다', '이미지를 자동 압축한다', '스크린리더를 비활성화한다', 'DOM 트리를 줄인다'],
|
||||
},
|
||||
{
|
||||
id: 'web-responsive-4',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-responsive',
|
||||
body: '모바일 브라우저에서 100vh 사용 시 생길 수 있는 대표 문제는 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '주소창 표시/숨김에 따라 실제 보이는 높이와 계산된 vh가 달라 하단 잘림이 생길 수 있습니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['CSS', '모바일'],
|
||||
correctRate: 0.44,
|
||||
choices: ['글자 수가 줄어든다', '브라우저 UI 높이 변화로 화면이 잘릴 수 있다', '쿠키 저장이 안 된다', '이미지가 모두 고정된다'],
|
||||
},
|
||||
{
|
||||
id: 'web-performance-1',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-performance',
|
||||
body: '웹 폰트 로딩으로 인한 렌더링 지연을 줄일 때 검토할 속성으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: '`font-display`를 적절히 설정하면 초기 텍스트 표시 전략을 제어할 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['CSS', '성능'],
|
||||
correctRate: 0.6,
|
||||
choices: ['object-fit', 'tabindex', 'font-display', 'z-index'],
|
||||
},
|
||||
{
|
||||
id: 'web-performance-2',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-performance',
|
||||
body: '이미지 지연 로딩(lazy loading)의 직접적인 효과로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '초기 뷰포트 밖 이미지를 나중에 불러와 첫 화면 로딩 부담을 줄일 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['HTML', '성능'],
|
||||
correctRate: 0.72,
|
||||
choices: ['서버 시간을 고정한다', '모든 이미지를 더 크게 만든다', '스크린리더를 끈다', '초기 네트워크 요청량을 줄여 첫 렌더를 가볍게 한다'],
|
||||
},
|
||||
{
|
||||
id: 'web-performance-3',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-performance',
|
||||
body: 'CLS(Cumulative Layout Shift)를 줄이기 위한 방법으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '이미지와 광고 영역에 미리 크기를 확보해 두면 렌더 후 갑작스러운 밀림을 줄일 수 있습니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['성능', 'CLS'],
|
||||
correctRate: 0.46,
|
||||
choices: ['이미지와 동적 영역의 크기를 미리 예약한다', '애니메이션을 모두 제거한다', 'HTML 파일을 여러 개로 분리한다', '모든 버튼을 absolute로 둔다'],
|
||||
},
|
||||
{
|
||||
id: 'web-performance-4',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-performance',
|
||||
body: '크리티컬 CSS를 인라인으로 우선 제공하는 목적은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '첫 화면 렌더에 필요한 최소 스타일을 먼저 적용해 초기 표시 속도를 높이기 위함입니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['CSS', '성능'],
|
||||
correctRate: 0.43,
|
||||
choices: ['자바스크립트를 금지하려고', '첫 화면 렌더에 필요한 스타일 적용을 앞당기려고', '모든 스타일 파일을 삭제하려고', '폰트 종류를 하나로 제한하려고'],
|
||||
},
|
||||
{
|
||||
id: 'web-browser-1',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-browser',
|
||||
body: '브라우저 기본 스타일 차이로 인한 오차를 줄이는 일반적인 접근으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '3',
|
||||
explanation: 'reset 또는 normalize 스타일을 적용해 기본 마진과 요소 표현 차이를 줄입니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['CSS', '브라우저호환성'],
|
||||
correctRate: 0.65,
|
||||
choices: ['모든 태그를 span으로 바꾼다', '이미지 파일명을 짧게 만든다', 'reset/normalize 스타일을 적용한다', '스크롤을 비활성화한다'],
|
||||
},
|
||||
{
|
||||
id: 'web-browser-2',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-browser',
|
||||
body: '특정 CSS 기능 사용 전 브라우저 지원 범위를 확인해야 하는 이유는 무엇인가요?',
|
||||
answerValue: '4',
|
||||
explanation: '지원하지 않는 환경에서는 레이아웃이 깨지거나 대체 스타일이 필요할 수 있습니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['CSS', '호환성'],
|
||||
correctRate: 0.66,
|
||||
choices: ['폰트 파일 수를 늘리려고', 'DOM 깊이를 줄이려고', '테스트 케이스를 삭제하려고', '일부 사용자 환경에서 기능이 동작하지 않을 수 있어서'],
|
||||
},
|
||||
{
|
||||
id: 'web-browser-3',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-browser',
|
||||
body: '입력 폼 자동완성 스타일이 브라우저별로 다를 때 우선 고려할 점으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '1',
|
||||
explanation: '브라우저 기본 UI는 완전한 통제가 어려우므로 사용성 저하 없이 허용 범위를 정하는 접근이 필요합니다.',
|
||||
difficulty: 'hard',
|
||||
tags: ['HTML', '브라우저호환성'],
|
||||
correctRate: 0.42,
|
||||
choices: ['브라우저 기본 UI를 모두 제거하려 하기보다 허용 범위를 정한다', 'label 태그를 없앤다', 'submit 버튼을 숨긴다', '모든 입력을 textarea로 바꾼다'],
|
||||
},
|
||||
{
|
||||
id: 'web-browser-4',
|
||||
examId: 'web-general',
|
||||
subjectId: 'html-css',
|
||||
setId: 'web-browser',
|
||||
body: '모바일 Safari에서 터치 영역이 작게 느껴질 때 우선 검토할 항목으로 가장 적절한 것은 무엇인가요?',
|
||||
answerValue: '2',
|
||||
explanation: '시각적 크기보다 실제 패딩과 line-height, 최소 터치 크기 확보가 중요합니다.',
|
||||
difficulty: 'medium',
|
||||
tags: ['모바일', 'UX'],
|
||||
correctRate: 0.7,
|
||||
choices: ['배경색만 바꾼다', '버튼 패딩과 실제 클릭 영역 크기를 늘린다', '스크롤을 잠근다', '폰트를 모두 소문자로 바꾼다'],
|
||||
},
|
||||
];
|
||||
@@ -1,57 +1,26 @@
|
||||
# Widgets Package Guide
|
||||
# Widgets
|
||||
|
||||
`src/widgets`는 여러 공통 컴포넌트를 묶어 하나의 카드형 샘플 또는 기능 단위 블록으로 제공하는 패키지입니다. 위젯은 앱의 `APIs / Widgets` 영역과 샘플 레이아웃에서 직접 소비됩니다.
|
||||
`src/widgets`는 공통 컴포넌트를 묶은 카드형 기능 블록입니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 여러 공통 컴포넌트를 묶어 재사용 가능한 기능 블록을 제공합니다.
|
||||
- 샘플, 문서, 기능 데모에서 같은 위젯 구성을 반복 사용합니다.
|
||||
- 위젯 메타데이터와 표시 규칙을 registry 기반으로 일관되게 관리합니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 위젯에는 API 호출, DB 접근, 라우팅, 화면 전용 상태, 비즈니스 로직을 직접 넣지 않습니다.
|
||||
- 위젯 설계는 최대한 멍청하게 유지합니다. 직관적인 props를 받고, 그 props에 따라 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 상태 orchestration은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 위젯은 어디에서나 재사용될 수 있으므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서 확장하거나 보완합니다.
|
||||
|
||||
## 현재 하위 구조
|
||||
|
||||
- `core`: 위젯 공통 셸, feature registry, 타입
|
||||
- `ag-grid-widget`
|
||||
- `api-sample-card`
|
||||
- `dashboard-report-card`
|
||||
- `gps-sample-card`
|
||||
- `text-memo-widget`
|
||||
- `registry.ts`: 위젯 목록과 메타데이터 등록점
|
||||
|
||||
## 폴더 구성 규약
|
||||
|
||||
위젯 패키지는 가능하면 아래 구조를 따릅니다.
|
||||
## 구조
|
||||
|
||||
```text
|
||||
widget-name/
|
||||
├─ WidgetName.tsx
|
||||
├─ WidgetName.css
|
||||
└─ index.ts
|
||||
src/widgets
|
||||
├─ core
|
||||
├─ ag-grid-widget
|
||||
├─ api-sample-card
|
||||
├─ dashboard-report-card
|
||||
├─ gps-sample-card
|
||||
├─ text-memo-widget
|
||||
└─ registry.ts
|
||||
```
|
||||
|
||||
- 위젯 진입점은 각 폴더의 `index.ts`입니다.
|
||||
- 카드형 레이아웃이 필요하면 `WidgetShell`을 우선 사용합니다.
|
||||
- 위젯 관련 공통 타입과 feature 정의는 `src/widgets/core`에 둡니다.
|
||||
- 위젯 메타데이터는 `src/widgets/registry.ts`에 등록합니다.
|
||||
- `core`: 공통 셸, 타입, registry 보조 코드
|
||||
- 각 위젯 폴더: 실제 위젯 구현
|
||||
- `registry.ts`: 위젯 메타데이터 등록점
|
||||
|
||||
## 구현 규약
|
||||
## 기준
|
||||
|
||||
- 위젯 ID는 `registry.ts`의 `id`와 컴포넌트 폴더명을 동일하게 맞춥니다.
|
||||
- 위젯 제목과 설명은 registry를 단일 기준으로 관리하고, 화면 문자열을 위젯 내부에 중복 선언하지 않습니다.
|
||||
- 기능 태그는 `WidgetFeatureKey` 범위 안에서만 사용하고, 새 태그가 필요하면 `core/types/widget.ts`와 `core/registry/widget-features.ts`를 함께 수정합니다.
|
||||
- 스크롤 이동이나 포커스 제어가 필요한 위젯은 `WidgetHandle` 계약을 따릅니다.
|
||||
- 위젯이 공통 컴포넌트를 조합해도 프로젝트 전용 비즈니스 로직이 강하면 `src/features`로 이동할지 먼저 검토합니다.
|
||||
|
||||
## 문서 및 샘플 규약
|
||||
|
||||
- 위젯은 문서보다 동작 예제가 중요하므로 registry 설명과 샘플 화면에서 바로 이해될 수 있게 유지합니다.
|
||||
- 컴포넌트 문서와 직접 연결되는 위젯은 `features`에 `docs` 또는 `component-sample` 태그를 넣어 의도를 드러냅니다.
|
||||
- API 연동 위젯은 데이터 소스, 실패 상태, 저장 동작을 설명하는 문서를 기능 문서 또는 관련 컴포넌트 문서에 남깁니다.
|
||||
- 위젯 구조나 공통 계약이 바뀌면 이 문서와 `registry.ts` 설명을 함께 갱신합니다.
|
||||
- 위젯은 카드형 조합 단위로 유지합니다.
|
||||
- 제목, 설명, 기능 태그는 registry를 단일 기준으로 관리합니다.
|
||||
- 프로젝트 전용 로직이 강해지면 `src/features`로 이동을 우선 검토합니다.
|
||||
|
||||
Reference in New Issue
Block a user