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