feat: refine codex live chat context flows

This commit is contained in:
2026-05-08 21:15:51 +09:00
parent 82c0d8a197
commit 442879313f
92 changed files with 14815 additions and 7314 deletions

View File

@@ -0,0 +1 @@
@import './ManagementPage.shared.css';

View File

@@ -9,7 +9,7 @@ import {
useAutomationContextRegistry,
} from './automationContextAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
import './AutomationContextManagementPage.css';
const { Text, Title } = Typography;

View File

@@ -0,0 +1 @@
@import './ManagementPage.shared.css';

View File

@@ -18,7 +18,7 @@ import {
type AutomationTypeRecord,
} from './automationTypeAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
import './AutomationTypeManagementPage.css';
const { Text, Title } = Typography;

View File

@@ -0,0 +1 @@
@import './ManagementPage.shared.css';

View File

@@ -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
View 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';

View File

@@ -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

View File

@@ -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="대화방을 삭제할까요?"

View File

@@ -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: '메시지 복사에 실패했습니다.' });

View 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;
}
}

View File

@@ -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;

View File

@@ -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, '&quot;');
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>

View File

@@ -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;
});
},
};
}

View File

@@ -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;
},
};

View File

@@ -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,

View File

@@ -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}
/>
);
}

View File

@@ -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);
},

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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[]>([]);

View 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>
);
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -13,6 +13,7 @@ export {
createIntroMessage,
createLocalMessage,
cancelChatRuntimeJob,
clearChatConversationRoom,
deleteChatConversationRequest,
deleteChatConversationRoom,
fetchChatConversationDetail,

View File

@@ -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);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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> = {

View File

@@ -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> = {

View File

@@ -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,
);

View File

@@ -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`로 분리합니다.

View 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>
);
}

View File

@@ -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) {

View File

@@ -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('복사에 실패했습니다.');

View File

@@ -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;
}

View File

@@ -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('복사에 실패했습니다.');

View File

@@ -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 : '공통 메모 복사에 실패했습니다.');

View File

@@ -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`의 기능 명세는 위젯 스펙 문서가 아니라 현재 레이아웃 안에서의 역할 설명으로 취급합니다.

View File

@@ -1,9 +1,9 @@
# Features Overview
이 영역은 현재 프로젝트에 종속된 기능과 화면 구성을 관리합니다.
`src/features`는 프로젝트 전용 기능 영역입니다.
## 목적
## 구조 기준
- 공통 `components`, `widgets`와 분리된 프로젝트 전용 기능 관리
- 기능별 문서, 화면 조합, 레이아웃을 한 곳에서 정리
- 향후 `dashboard`, `sampleBoard`, `docsViewer` 같은 프로젝트 전용 기능 확장
- 공통 UI로 분리하기 어려운 화면 로직은 `src/features`에 둡니다.
- 재사용 가능한 UI는 `src/components`, 카드형 조합은 `src/widgets`로 분리합니다.
- 레이아웃 전용 기능은 `src/features/layout`에서 관리합니다.

View File

@@ -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>

View File

@@ -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`;

View File

@@ -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;

View File

@@ -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>
}

View File

@@ -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),
};
}

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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
View 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('브라우저가 클립보드 복사를 차단했습니다.');
}

View File

@@ -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) {

View File

@@ -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: ['필요한 최소 권한만 부여한다', '관리자 권한을 기본값으로 준다', '모든 로그를 삭제한다', '암호를 화면에 표시한다'],

View File

@@ -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];

View 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: ['배경색만 바꾼다', '버튼 패딩과 실제 클릭 영역 크기를 늘린다', '스크롤을 잠근다', '폰트를 모두 소문자로 바꾼다'],
},
];

View File

@@ -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`로 이동을 우선 검토합니다.