Fix chat type persistence and board flow

This commit is contained in:
2026-04-24 15:56:30 +09:00
parent c07b0b12af
commit d53532508b
38 changed files with 2358 additions and 912 deletions

View File

@@ -1,12 +1,13 @@
import {
ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
SaveOutlined,
PlusOutlined,
ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
@@ -78,8 +79,13 @@ export function AutomationTypeManagementPage() {
}, [automationTypes, selectedAutomationTypeId]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
}, [form, isCreating, selectedAutomationType]);
}, [detailMode, form, isCreating, selectedAutomationType]);
useEffect(() => {
if (detailMode !== 'detail') {
@@ -163,6 +169,41 @@ export function AutomationTypeManagementPage() {
}
};
const detailHeaderActions = (
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Tooltip title={isCreating ? '등록' : '수정 저장'}>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
loading={isSaving}
aria-label={isCreating ? '등록' : '수정 저장'}
onClick={() => {
void form.submit();
}}
/>
</Tooltip>
<Tooltip title="새 입력">
<Button shape="circle" icon={<PlusOutlined />} disabled={isSaving} aria-label="새 입력" onClick={openCreateForm} />
</Tooltip>
{!isCreating && selectedAutomationType ? (
<Tooltip title="삭제">
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
loading={isSaving}
aria-label="삭제"
onClick={() => void handleDelete()}
/>
</Tooltip>
) : null}
<Tooltip title="목록 가기">
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
</Tooltip>
</Space>
);
if (!hasAccess) {
return (
<Card title="자동화 유형 관리" className="chat-type-management-page">
@@ -250,13 +291,11 @@ export function AutomationTypeManagementPage() {
<Card
title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
extra={detailHeaderActions}
>
<div className="chat-type-management-page__editor">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}>{isCreating ? '신규 자동화 유형 등록' : selectedAutomationType?.name ?? '자동화 유형 수정'}</Title>
</div>
<Form
className="chat-type-management-page__editor-form"
@@ -290,172 +329,150 @@ export function AutomationTypeManagementPage() {
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="유형명"
name="name"
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
>
<Input placeholder="예: 자동화 메모" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
<div className="chat-type-management-page__editor-scroll">
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="유형명"
name="name"
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Input placeholder="예: 자동화 메모" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '축소' : '최대화'}
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
) : null}
</div>
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
}
/>
</Form.Item>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
{maximizedPane === 'edit' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 12, maxRows: 24 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
}
/>
</Form.Item>
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
);
}}
</Form.Item>
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={`chat-type-management-page__form-actions${
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
}`}
>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
{!isCreating && selectedAutomationType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
</div>
</Form>
</div>
</Card>

View File

@@ -2,6 +2,8 @@
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
@@ -17,21 +19,31 @@
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: 52px;
padding: 0 14px;
min-height: 44px;
padding: 0 12px;
}
.chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra {
padding: 10px 0;
padding: 6px 0;
}
.chat-type-management-page .ant-card-body {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 12px 14px;
padding: 4px 14px 12px;
}
.chat-type-management-page__list,
@@ -40,7 +52,7 @@
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
height: 100%;
overflow: hidden;
}
@@ -57,12 +69,23 @@
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
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 8px;
}
.chat-type-management-page__editor-form .ant-form-item {
margin-bottom: 8px;
margin-bottom: 6px;
}
.chat-type-management-page__list-header {
@@ -73,7 +96,7 @@
}
.chat-type-management-page__list-header .ant-typography {
margin-bottom: 0;
margin: 0;
}
.chat-type-management-page__item {
@@ -137,15 +160,28 @@
.chat-type-management-page__editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
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, 1fr) minmax(0, 1fr);
gap: 6px;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
gap: 12px;
align-items: stretch;
flex: 1;
min-height: 0;
@@ -173,6 +209,15 @@
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;
}
@@ -191,13 +236,13 @@
.chat-type-management-page__markdown-textarea {
height: 100% !important;
min-height: 180px;
min-height: 360px;
resize: none;
}
.chat-type-management-page__markdown-textarea textarea {
height: 100% !important;
min-height: 180px;
min-height: 360px;
overflow: auto !important;
resize: none;
}
@@ -209,10 +254,10 @@
border: 1px solid #f0f0f0;
border-radius: 12px;
background: #fafafa;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
overflow: hidden;
}
@@ -226,23 +271,11 @@
margin-bottom: 0;
}
.chat-type-management-page__form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 2px;
}
.chat-type-management-page__form-actions--compact {
padding-top: 0;
}
.chat-type-management-page__meta-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: start;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px 14px;
align-items: end;
}
.chat-type-management-page__meta-grid--hidden {
@@ -251,16 +284,45 @@
.chat-type-management-page__meta-item {
min-width: 0;
margin-bottom: 0;
}
.chat-type-management-page__meta-item .ant-form-item-label {
padding-bottom: 4px;
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__markdown-field,
@@ -293,6 +355,23 @@
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;
}
@@ -305,7 +384,18 @@
.chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra,
.chat-type-management-page .ant-card-body {
padding: 10px;
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 {
@@ -314,28 +404,83 @@
.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);
gap: 6px;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px 12px;
align-items: start;
}
.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;
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__form-actions {
flex-wrap: wrap;
.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__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,12 +1,13 @@
import {
ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
SaveOutlined,
PlusOutlined,
ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
@@ -82,8 +83,13 @@ export function ChatTypeManagementPage() {
}, [chatTypes, selectedChatTypeId]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
}, [form, isCreating, selectedChatType]);
}, [detailMode, form, isCreating, selectedChatType]);
useEffect(() => {
if (detailMode !== 'detail') {
@@ -145,7 +151,7 @@ export function ChatTypeManagementPage() {
return;
}
if (!window.confirm(`"${selectedChatType.name}" 컨텍스트를 삭제할까요?`)) {
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) {
return;
}
@@ -167,6 +173,52 @@ export function ChatTypeManagementPage() {
}
};
const detailHeaderActions = (
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Tooltip title={isCreating ? '저장' : '수정 저장'}>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
loading={isSaving}
aria-label={isCreating ? '저장' : '수정 저장'}
onClick={() => {
void form.submit();
}}
/>
</Tooltip>
<Tooltip title="새 입력">
<Button
shape="circle"
icon={<PlusOutlined />}
disabled={isSaving}
aria-label="새 입력"
onClick={openCreateForm}
/>
</Tooltip>
{!isCreating && selectedChatType ? (
<Tooltip title="비활성화">
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
loading={isSaving}
aria-label="비활성화"
onClick={() => void handleDelete()}
/>
</Tooltip>
) : null}
<Tooltip title="목록 가기">
<Button
shape="circle"
icon={<UnorderedListOutlined />}
aria-label="목록 가기"
onClick={closeDetail}
/>
</Tooltip>
</Space>
);
if (!hasAccess) {
return (
<Card title="컨텍스트 권한 관리" className="chat-type-management-page">
@@ -267,13 +319,11 @@ export function ChatTypeManagementPage() {
<Card
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
extra={detailHeaderActions}
>
<div className="chat-type-management-page__editor">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
</div>
<Form
className="chat-type-management-page__editor-form"
@@ -301,187 +351,165 @@ export function ChatTypeManagementPage() {
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="컨텍스트명"
name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
>
<Input placeholder="예: 운영 문의" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
label="권한 대상"
name="permissions"
>
<Checkbox.Group
options={[
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
]}
/>
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
<div className="chat-type-management-page__editor-scroll">
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="컨텍스트명"
name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Input placeholder="예: 운영 문의" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
label="사용 권한"
name="permissions"
>
<Checkbox.Group
options={[
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
]}
/>
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '축소' : '최대화'}
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
) : null}
</div>
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
}
/>
</Form.Item>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
{maximizedPane === 'edit' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
}
/>
</Form.Item>
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="미리보기할 문맥 설명이 없습니다."
/>
);
}}
</Form.Item>
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="미리보기할 문맥 설명이 없습니다."
/>
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={`chat-type-management-page__form-actions${
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
}`}
>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
{!isCreating && selectedChatType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
</div>
</Form>
</div>
</Card>

View File

@@ -2131,12 +2131,12 @@
z-index: 4;
display: flex;
flex-direction: column;
gap: 6px;
width: min(240px, calc(100% - 16px));
max-height: min(28vh, 180px);
padding: 8px;
gap: 8px;
width: min(420px, calc(100% - 16px));
max-height: min(58vh, 520px);
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 12px;
border-radius: 16px;
background: rgba(246, 248, 252, 0.96);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
overflow: hidden;
@@ -2145,7 +2145,7 @@
.app-chat-panel__resource-strip-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
overflow: auto;
}
@@ -2171,22 +2171,18 @@
line-height: 1.5;
}
.app-chat-panel__resource-chip {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
width: 100%;
padding: 6px 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 10px;
background: rgba(255, 255, 255, 0.9);
color: #0f172a;
cursor: pointer;
font-size: 11px;
text-align: left;
.app-chat-panel__resource-strip .app-chat-preview-card {
margin: 0;
}
.app-chat-panel__resource-strip .app-chat-preview-card__body {
padding-top: 8px;
}
.app-chat-panel__resource-strip .app-chat-panel__preview-rich,
.app-chat-panel__resource-strip .previewer-ui__editor,
.app-chat-panel__resource-strip .previewer-ui__editor-body {
min-height: 0;
}
.app-chat-panel__preview-stage {
@@ -2596,10 +2592,6 @@
padding-bottom: 2px;
}
.app-chat-panel__resource-chip {
min-width: 160px;
}
.app-chat-panel__preview-image,
.app-chat-panel__preview-video,
.app-chat-panel__preview-frame,

View File

@@ -6,6 +6,7 @@ import {
CopyOutlined,
DownloadOutlined,
EditOutlined,
EyeOutlined,
ExclamationCircleOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
@@ -34,6 +35,7 @@ import { useConversationViewportController } from './chatV2/hooks/useConversatio
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl';
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
import { extractAutoDetectedPreviewUrls } from './mainChatPanel/inlinePreviewUrls';
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
@@ -47,9 +49,7 @@ import {
createChatMessage,
createLocalMessage,
ErrorLogViewer,
getStoredChatSessionLastTypeId,
isPreparingChatReplyText,
setStoredChatSessionLastTypeId,
sortChatConversationSummaries,
upsertChatMessage,
useErrorLogs,
@@ -666,7 +666,7 @@ function isPreviewRouteUrl(url: string) {
const parsed = new URL(url, window.location.origin);
const pathname = parsed.pathname.toLowerCase();
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
return !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
} catch {
return false;
}
@@ -725,14 +725,28 @@ function buildPreviewLabel(url: string, source: PreviewItem['source']) {
}
}
function isHtmlPreviewItem(item: PreviewItem | null | undefined) {
if (!item || item.kind !== 'code') {
return false;
}
try {
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const pathname = parsed.pathname.toLowerCase();
return pathname.endsWith('.html') || pathname.endsWith('.htm');
} catch {
const pathname = item.url.toLowerCase().split('?')[0] ?? '';
return pathname.endsWith('.html') || pathname.endsWith('.htm');
}
}
function extractPreviewItems(messages: ChatMessage[]) {
const urlPattern = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
const seen = new Set<string>();
const items: PreviewItem[] = [];
const orderedMessages = [...messages].reverse();
orderedMessages.forEach((message) => {
const matches = [...(message.text.match(urlPattern) ?? []), ...extractHiddenPreviewUrls(message.text)];
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
matches.forEach((matchedUrl) => {
const normalizedUrl = normalizePreviewUrl(matchedUrl);
@@ -895,7 +909,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
[chatTypes, userRoles],
);
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(availableChatTypes[0]?.id ?? null);
const selectedChatType = availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false;
const requestedSessionId = getSessionIdFromSearch(location.search);
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
const requestedChatView = getRequestedChatViewFromSearch(location.search);
@@ -940,6 +955,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
const previewSearchMatchIndexRef = useRef(-1);
const previewSearchKeyRef = useRef('');
const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false);
const titleClusterRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimerRef = useRef<number | null>(null);
const pendingRequestsRef = useRef<PendingChatRequest[]>([]);
@@ -950,7 +966,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const shouldRestoreConversationAfterReconnectRef = useRef(false);
const handledRequestedSessionIdRef = useRef('');
const isClosingConversationRef = useRef(false);
const lastChatTypeSessionIdRef = useRef('');
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
@@ -966,19 +981,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
});
}, []);
const currentContext: ChatViewContext = {
pageId: currentPage.id,
pageTitle: currentPage.title,
topMenu: currentPage.topMenu,
focusedComponentId,
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
isStandaloneMode: isStandaloneDisplayMode(),
pageVisibilityState:
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
chatTypeId: selectedChatType?.id ?? null,
chatTypeLabel: selectedChatType?.name ?? '',
chatTypeDescription: selectedChatType?.description ?? '',
};
const {
conversationItems,
setConversationItems,
@@ -1029,14 +1031,16 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const handleCreateConversation = async () => {
const sessionId = createConversationSessionId();
const now = new Date().toISOString();
const nextConversationChatType =
selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null);
const optimisticItem: ChatConversationSummary = {
sessionId,
clientId: null,
title: '새 대화',
chatTypeId: selectedChatType?.id ?? null,
lastChatTypeId: selectedChatType?.id ?? null,
contextLabel: selectedChatType?.name ?? null,
contextDescription: selectedChatType?.description ?? null,
chatTypeId: nextConversationChatType?.id ?? null,
lastChatTypeId: nextConversationChatType?.id ?? null,
contextLabel: nextConversationChatType?.name ?? null,
contextDescription: nextConversationChatType?.description ?? null,
notifyOffline: true,
hasUnreadResponse: false,
currentRequestId: null,
@@ -1059,10 +1063,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const item = await chatGateway.createConversation({
sessionId,
title: '새 대화',
chatTypeId: selectedChatType?.id ?? null,
lastChatTypeId: selectedChatType?.id ?? null,
contextLabel: selectedChatType?.name,
contextDescription: selectedChatType?.description,
chatTypeId: nextConversationChatType?.id ?? null,
lastChatTypeId: nextConversationChatType?.id ?? null,
contextLabel: nextConversationChatType?.name,
contextDescription: nextConversationChatType?.description,
notifyOffline: true,
});
@@ -1384,6 +1388,63 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return;
}
};
const previewItems = useMemo(
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))),
[messages],
);
const isTabletAppLayout = isMobileViewport;
const chatMessages = useMemo(
() => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)),
[messages],
);
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
const activeConversation = useMemo(
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
[activeSessionId, conversationItems],
);
const persistedActiveChatTypeId =
activeConversation?.chatTypeId?.trim() || activeConversation?.lastChatTypeId?.trim() || null;
const effectiveChatType = useMemo(() => {
if (persistedActiveChatTypeId) {
const persistedChatType = chatTypes.find((item) => item.id === persistedActiveChatTypeId);
if (persistedChatType) {
return persistedChatType;
}
return {
id: persistedActiveChatTypeId,
name: activeConversation?.contextLabel?.trim() || persistedActiveChatTypeId,
description: activeConversation?.contextDescription?.trim() || '',
};
}
return selectedChatType;
}, [
activeConversation?.contextDescription,
activeConversation?.contextLabel,
chatTypes,
persistedActiveChatTypeId,
selectedChatType,
]);
const effectiveChatTypeId = effectiveChatType?.id ?? null;
const effectiveRegisteredChatType =
effectiveChatType ? chatTypes.find((item) => item.id === effectiveChatType.id) ?? null : null;
const isEffectiveChatTypeAllowed = effectiveRegisteredChatType
? canUseChatType(effectiveRegisteredChatType, userRoles)
: false;
const currentContext: ChatViewContext = {
pageId: currentPage.id,
pageTitle: currentPage.title,
topMenu: currentPage.topMenu,
focusedComponentId,
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
isStandaloneMode: isStandaloneDisplayMode(),
pageVisibilityState:
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
chatTypeId: effectiveChatType?.id ?? null,
chatTypeLabel: effectiveChatType?.name ?? '',
chatTypeDescription: effectiveChatType?.description ?? '',
};
const { socketRef, connectionState } = chatConnectionGateway.useConnection({
sessionId: activeSessionId,
currentContext,
@@ -1410,21 +1471,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
activeView,
hasAccess,
});
const previewItems = useMemo(
() => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))),
[messages],
);
const isTabletAppLayout = isMobileViewport;
const chatMessages = useMemo(
() => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)),
[messages],
);
const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
const activeConversation = useMemo(
() => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
[activeSessionId, conversationItems],
);
const activeRuntimeStatus = useMemo(
() => buildRuntimeStatusLabel(runtimeSnapshot, activeSessionId),
[runtimeSnapshot, activeSessionId],
@@ -1576,7 +1622,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
activeSessionId,
activeView,
previewItems,
selectedChatTypeId: selectedChatType?.id ?? null,
selectedChatTypeId,
composerRef,
setActiveSystemStatus,
setComposerAttachments,
@@ -1634,10 +1680,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
}, [activePreview, messageApi]);
const isActivePreviewHtml = isHtmlPreviewItem(activePreview);
const canSearchActivePreview =
Boolean(activePreview) &&
!isPreviewLoading &&
!previewError.trim() &&
!(isActivePreviewHtml && isHtmlPreviewMode) &&
(activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document');
const resetActivePreviewSearchState = useCallback(() => {
@@ -1673,6 +1722,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
clearActivePreviewSearchSelection();
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
useEffect(() => {
setIsHtmlPreviewMode(false);
}, [activePreview?.id, isPreviewModalOpen]);
useEffect(() => {
resetActivePreviewSearchState();
}, [previewFindQuery, resetActivePreviewSearchState]);
@@ -2254,25 +2307,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, []);
useEffect(() => {
const hasSessionChanged = lastChatTypeSessionIdRef.current !== activeSessionId;
lastChatTypeSessionIdRef.current = activeSessionId;
if (activeSessionId) {
if (hasSessionChanged) {
const lastUsedChatTypeId =
activeConversation?.lastChatTypeId?.trim() || getStoredChatSessionLastTypeId(activeSessionId);
if (!activeConversation) {
return;
}
if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) {
if (selectedChatTypeId !== lastUsedChatTypeId) {
setSelectedChatTypeId(lastUsedChatTypeId);
}
return;
}
const persistedChatTypeId =
activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null;
const defaultChatTypeId = availableChatTypes[0]?.id ?? null;
if (selectedChatTypeId !== defaultChatTypeId) {
setSelectedChatTypeId(defaultChatTypeId);
if (persistedChatTypeId) {
if (selectedChatTypeId !== persistedChatTypeId) {
setSelectedChatTypeId(persistedChatTypeId);
}
return;
}
@@ -2283,34 +2328,38 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
}, [activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
}, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
useEffect(() => {
if (!activeSessionId || !selectedChatTypeId) {
if (!activeSessionId || !selectedChatTypeId || !selectedChatType) {
return;
}
setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId);
setConversationItems((previous) =>
previous.map((item) =>
item.sessionId === activeSessionId && item.lastChatTypeId !== selectedChatTypeId
? { ...item, lastChatTypeId: selectedChatTypeId }
: item,
),
);
const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null;
if (currentLastChatTypeId === selectedChatTypeId) {
const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null;
if (currentChatTypeId) {
return;
}
void chatGateway.updateConversation(activeSessionId, {
chatTypeId: selectedChatTypeId,
lastChatTypeId: selectedChatTypeId,
contextLabel: selectedChatType.name,
contextDescription: selectedChatType.description,
}).then((item) => {
setConversationItems((previous) =>
previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)),
);
}).catch(() => {
// Ignore background sync failures and keep local in-memory fallback.
});
}, [activeConversation?.lastChatTypeId, activeSessionId, selectedChatTypeId, setConversationItems]);
}, [
activeConversation?.chatTypeId,
activeConversation?.lastChatTypeId,
activeSessionId,
selectedChatType,
selectedChatTypeId,
setConversationItems,
]);
useEffect(() => {
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
@@ -2600,7 +2649,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
draft,
composerAttachments,
isComposerAttachmentUploading,
selectedChatType,
selectedChatType: effectiveChatType
? {
id: effectiveChatType.id,
name: effectiveChatType.name,
description: effectiveChatType.description,
}
: null,
socketRef,
composerRef,
messagesRef,
@@ -2614,7 +2669,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setIsSystemStatusPending,
setShowScrollToBottom,
setPendingContextConfirm,
setStoredChatSessionLastTypeId,
upsertRequestItem,
syncConversationPreviewForRequest,
updatePendingMessageStatus,
@@ -2924,7 +2978,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
isPullToLoadArmed={isPullToLoadArmed}
pullToLoadDistance={pullToLoadDistance}
requestStateMap={activeRequestMap}
selectedChatTypeId={selectedChatType?.id ?? null}
selectedChatTypeId={effectiveChatTypeId}
queuedRequests={activeQueuedComposerRequests.map((item, index) => ({
requestId: item.requestId,
order: index + 1,
@@ -2933,7 +2987,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
chatTypeOptions={chatTypeOptions}
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
isResourceStripOpen={isResourceStripOpen}
isComposerDisabled={!selectedChatType}
isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed}
isChatTypeSelectionLocked={Boolean(activeConversation?.chatTypeId?.trim())}
isComposerAttachmentUploading={isComposerAttachmentUploading}
onViewportScroll={handleViewportScroll}
onViewportTouchEnd={handleViewportTouchEnd}
@@ -2944,7 +2999,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
onRemoveComposerAttachment={(attachmentId) => {
setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId));
}}
onSelectChatType={setSelectedChatTypeId}
onSelectChatType={(nextChatTypeId) => {
if (activeConversation?.chatTypeId?.trim()) {
return;
}
setSelectedChatTypeId(nextChatTypeId);
}}
onSend={handleSend}
onSendImmediate={handleSendImmediate}
onClearDraft={() => {
@@ -3061,6 +3122,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
<div className="app-chat-panel__preview-modal-title">
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
<Space size={4} wrap>
{isActivePreviewHtml ? (
<Button
type={isHtmlPreviewMode ? 'default' : 'text'}
aria-label={isHtmlPreviewMode ? 'HTML 소스 보기' : 'HTML 미리보기'}
icon={<EyeOutlined />}
onClick={() => {
setIsHtmlPreviewMode((current) => !current);
}}
>
{isHtmlPreviewMode ? '소스' : '미리보기'}
</Button>
) : null}
{canSearchActivePreview ? (
<Button
type={isPreviewFindOpen ? 'default' : 'text'}
@@ -3141,6 +3214,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
previewError={previewError}
previewContentType={previewContentType}
maxMarkdownBlocks={undefined}
renderHtmlAsFrame={isActivePreviewHtml && isHtmlPreviewMode}
/>
</div>
</div>

View File

@@ -961,16 +961,28 @@ export function MainHeader({
const workServerPendingUpdateCount =
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
const hasBuildRequiredUpdate =
Boolean(testServerStatus?.buildRequired) ||
Boolean(prodServerStatus?.buildRequired) ||
Boolean(workServerStatus?.buildRequired);
const totalAutomationShortcutCount =
planShortcutCounts.working + planShortcutCounts.releasePendingMain + planShortcutCounts.automationFailed;
const settingsStatusClassName =
totalPendingUpdateCount >= 2
hasBuildRequiredUpdate
? 'app-header__status-dot--inactive'
: totalPendingUpdateCount >= 2
? 'app-header__status-dot--inactive'
: totalPendingUpdateCount === 1
? 'app-header__status-dot--warning'
: 'app-header__status-dot--active';
const settingsStatusLabel =
totalPendingUpdateCount >= 2 ? '모든 업데이트 존재' : totalPendingUpdateCount === 1 ? '업데이트 1건 존재' : '최신 상태';
hasBuildRequiredUpdate
? '커밋 미반영 업데이트 존재'
: totalPendingUpdateCount >= 2
? '모든 업데이트 존재'
: totalPendingUpdateCount === 1
? '업데이트 1건 존재'
: '최신 상태';
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0;
const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0;
@@ -2832,7 +2844,7 @@ export function MainHeader({
<>
<button
type="button"
className={connectionIndicatorClassName}
className={`${connectionIndicatorClassName} app-header__connection-indicator--labelled`}
aria-label={chatConnectionLabel}
title={chatConnectionLabel}
onClick={() => {
@@ -2843,6 +2855,12 @@ export function MainHeader({
<ApiOutlined />
<span className={`app-header__status-dot ${chatConnectionStatusClassName}`} />
</span>
<span className="app-header__connection-copy">
<span className="app-header__connection-title"></span>
<span className="app-header__connection-meta">
{hasPendingRuntimeWork ? `실행 ${runningRuntimeCount} · 대기 ${queuedRuntimeCount}` : '바로 열기'}
</span>
</span>
{runningRuntimeCount > 0 ? (
<span
className={connectionCountBadgeClassName}

View File

@@ -83,6 +83,14 @@
cursor: pointer;
}
.app-header__connection-indicator--labelled {
justify-content: flex-start;
gap: 8px;
width: auto;
min-width: 124px;
padding: 0 12px 0 10px;
}
.app-header__connection-indicator:hover {
background: #f3f7ff;
}
@@ -140,6 +148,28 @@
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.22);
}
.app-header__connection-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
min-width: 0;
line-height: 1.1;
}
.app-header__connection-title {
font-size: 13px;
font-weight: 700;
color: #182230;
white-space: nowrap;
}
.app-header__connection-meta {
font-size: 11px;
color: #64748b;
white-space: nowrap;
}
@keyframes app-header-connection-badge-pulse {
0%,
100% {
@@ -487,6 +517,12 @@
width: 100%;
}
.app-main-layout:has(.chat-type-management-page) {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 4px 12px 12px;
}
@media (max-width: 720px) {
html,
body,
@@ -734,6 +770,17 @@
height: 32px;
}
.app-header__connection-indicator--labelled {
min-width: 32px;
width: 32px;
padding: 0;
justify-content: center;
}
.app-header__connection-copy {
display: none;
}
.app-header__runtime-summary {
gap: 8px;
}
@@ -764,6 +811,11 @@
gap: 8px;
}
.app-main-layout:has(.chat-type-management-page) {
padding: 0;
gap: 0;
}
.app-main-window-layer {
inset: 8px;
}

View File

@@ -47,7 +47,7 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'none',
name: '기본유형',
description:
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',

View File

@@ -23,10 +23,6 @@ export type ChatTypeInput = {
const CHAT_TYPES_API_PATH = '/chat-types';
const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed';
const CHAT_TYPE_REQUEST_TIMEOUT_MS = 8000;
const LEGACY_CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types';
const LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-ids';
const LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-default-ids';
export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
guest: '게스트',
'token-user': '토큰 사용자',
@@ -50,6 +46,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z',
},
{
id: 'general-inquiry',
name: '일반 문의',
description:
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-24T00:00:00.000Z',
},
];
function normalizeText(value: string | null | undefined) {
@@ -218,68 +223,6 @@ async function requestChatTypes<T>(init?: RequestInit) {
}
}
function readLegacyDeletedChatTypeIds() {
if (typeof window === 'undefined') {
return new Set<string>();
}
try {
const rawDeletedIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY);
const rawLegacyDeletedDefaultIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY);
const deletedIds = [rawDeletedIds, rawLegacyDeletedDefaultIds]
.flatMap((raw) => {
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed : [];
})
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
return new Set(deletedIds);
} catch {
return new Set<string>();
}
}
function readLegacyChatTypes() {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as Partial<ChatTypeRecord>[];
if (!Array.isArray(parsed)) {
return null;
}
const deletedIds = readLegacyDeletedChatTypeIds();
const normalized = sanitizeChatTypes(parsed).filter((item) => !deletedIds.has(item.id));
return normalized;
} catch {
return null;
}
}
function clearLegacyChatTypeStorage() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY);
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY);
}
async function fetchChatTypesFromServer() {
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({
method: 'GET',
@@ -300,7 +243,6 @@ async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
});
emitChatTypesChange();
clearLegacyChatTypeStorage();
return sanitizeChatTypes(response.chatTypes);
}
@@ -333,7 +275,17 @@ export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string)
return sanitizeChatTypes(chatTypes);
}
return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
return sanitizeChatTypes(
chatTypes.map((item) =>
item.id === normalizedId
? {
...item,
enabled: false,
updatedAt: new Date().toISOString(),
}
: item,
),
);
}
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
@@ -359,13 +311,7 @@ export function useChatTypeRegistry() {
try {
const serverChatTypes = await fetchChatTypesFromServer();
let resolvedChatTypes = serverChatTypes;
if (resolvedChatTypes == null) {
const legacyChatTypes = readLegacyChatTypes();
resolvedChatTypes = legacyChatTypes ?? DEFAULT_CHAT_TYPES;
resolvedChatTypes = await saveChatTypesToServer(resolvedChatTypes);
}
const resolvedChatTypes = serverChatTypes ?? DEFAULT_CHAT_TYPES;
if (isMountedRef.current) {
setChatTypesState(resolvedChatTypes);

View File

@@ -18,6 +18,8 @@ import {
type ChatPreviewKind,
type ChatPreviewTarget,
} from '../../mainChatPanel/ChatPreviewBody';
import { extractAutoDetectedPreviewUrls } from '../../mainChatPanel/inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from '../../mainChatPanel/previewMarkers';
import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl';
import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils';
import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
@@ -35,12 +37,12 @@ type ConversationRoomPaneProps = {
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
type MessageRenderPayload = {
previewSourceText: string;
visibleText: string;
diffBlocks: string[];
};
@@ -132,7 +134,7 @@ function downloadTextFile(content: string, fileName: string, mimeType = 'text/pl
}
function extractInlinePreviewTargets(text: string): ChatPreviewTarget[] {
const matches = text.match(INLINE_PREVIEW_URL_PATTERN) ?? [];
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>();
const targets: ChatPreviewTarget[] = [];
@@ -220,12 +222,10 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
const visibleText = text
.replace(DIFF_CODE_BLOCK_PATTERN, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
const visibleText = stripHiddenPreviewTags(previewSourceText);
return { visibleText, diffBlocks };
return { previewSourceText, visibleText, diffBlocks };
}
function isLikelyCollapsibleMessage(text: string) {
@@ -574,8 +574,8 @@ export function ConversationRoomPane({
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');

View File

@@ -58,7 +58,6 @@ type UseConversationComposerControllerOptions = {
setIsSystemStatusPending: (value: boolean) => void;
setShowScrollToBottom: (value: boolean) => void;
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
setStoredChatSessionLastTypeId: (sessionId: string, chatTypeId: string) => void;
upsertRequestItem: (request: ChatConversationRequest) => void;
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
@@ -95,7 +94,6 @@ export function useConversationComposerController({
setIsSystemStatusPending,
setShowScrollToBottom,
setPendingContextConfirm,
setStoredChatSessionLastTypeId,
upsertRequestItem,
syncConversationPreviewForRequest,
updatePendingMessageStatus,
@@ -181,8 +179,6 @@ export function useConversationComposerController({
failed: false,
};
setStoredChatSessionLastTypeId(activeSessionId, chatTypeId);
if (mode === 'queue') {
const queuedAt = new Date().toISOString();
const optimisticUserMessage: ChatMessage = {
@@ -302,7 +298,6 @@ export function useConversationComposerController({
setIsSystemStatusPending,
setMessages,
setShowScrollToBottom,
setStoredChatSessionLastTypeId,
shouldStickToBottomRef,
socketRef,
syncConversationPreviewForRequest,

View File

@@ -33,6 +33,7 @@ import {
import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer';
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils';
@@ -83,7 +84,6 @@ type PreviewFetchError = Error & {
status?: number;
};
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
@@ -92,6 +92,7 @@ const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
type MessageRenderPayload = {
previewSourceText: string;
visibleText: string;
diffBlocks: string[];
};
@@ -169,6 +170,21 @@ function buildPreviewFileName(item: PreviewOption) {
}
}
function normalizePreviewOptionKind(kind: string): ChatPreviewKind {
switch (kind) {
case 'image':
case 'video':
case 'markdown':
case 'code':
case 'diff':
case 'document':
case 'pdf':
return kind;
default:
return 'file';
}
}
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
let responseMessage = '';
@@ -199,7 +215,7 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
}
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
const matches = [...(text.match(INLINE_PREVIEW_URL_PATTERN) ?? []), ...extractHiddenPreviewUrls(text)];
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>();
const targets: InlinePreviewTarget[] = [];
@@ -293,9 +309,11 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload {
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
const visibleText = stripHiddenPreviewTags(text.replace(DIFF_CODE_BLOCK_PATTERN, ''));
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
const visibleText = stripHiddenPreviewTags(previewSourceText);
return {
previewSourceText,
visibleText,
diffBlocks,
};
@@ -688,6 +706,7 @@ type ChatConversationViewProps = {
previewItems: PreviewOption[];
isResourceStripOpen: boolean;
isComposerDisabled: boolean;
isChatTypeSelectionLocked: boolean;
isComposerAttachmentUploading: boolean;
onViewportScroll: () => void;
onViewportTouchEnd: () => void;
@@ -733,6 +752,7 @@ export function ChatConversationView({
previewItems,
isResourceStripOpen,
isComposerDisabled,
isChatTypeSelectionLocked,
isComposerAttachmentUploading,
onViewportScroll,
onViewportTouchEnd,
@@ -756,6 +776,7 @@ export function ChatConversationView({
}: ChatConversationViewProps) {
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [expandedResourcePreviewKey, setExpandedResourcePreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
@@ -1191,17 +1212,22 @@ export function ChatConversationView({
</label>
<div className="app-chat-panel__resource-strip-list">
{visiblePreviewItems.map((item) => (
<button
key={item.id}
type="button"
className="app-chat-panel__resource-chip"
onClick={() => {
onOpenPreview(item.id);
}}
>
<span>{item.label}</span>
<span>{item.kind}</span>
</button>
<InlineMessagePreview
key={item.id}
target={{
label: item.label,
url: item.url,
kind: normalizePreviewOptionKind(item.kind),
}}
isExpanded={expandedResourcePreviewKey === item.id}
hasModalPreview
onOpenModalPreview={() => {
onOpenPreview(item.id, { fullscreen: true });
}}
onToggle={() => {
setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id));
}}
/>
))}
</div>
</>
@@ -1248,13 +1274,13 @@ export function ChatConversationView({
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
if (isActivityLogMessage(message)) {
return renderActivityCard(message);
}
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
@@ -1498,7 +1524,7 @@ export function ChatConversationView({
),
}))}
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
disabled={chatTypeOptions.length === 0}
disabled={chatTypeOptions.length === 0 || isChatTypeSelectionLocked}
onChange={onSelectChatType}
/>
</div>

View File

@@ -203,6 +203,33 @@ function canRenderFramePreview(url: string) {
}
}
function buildHtmlFrameDocument(html: string, sourceUrl: string) {
const trimmed = html.trim();
if (!trimmed) {
return '<!doctype html><html><body></body></html>';
}
const baseHref = (() => {
try {
return new URL('.', sourceUrl).toString();
} catch {
return sourceUrl;
}
})();
const baseTag = `<base href="${baseHref}">`;
if (/<head(\s|>)/i.test(trimmed)) {
return trimmed.replace(/<head(\s*[^>]*)>/i, (match) => `${match}${baseTag}`);
}
if (/<html(\s|>)/i.test(trimmed)) {
return trimmed.replace(/<html(\s*[^>]*)>/i, (match) => `${match}<head>${baseTag}</head>`);
}
return `<!doctype html><html><head>${baseTag}</head><body>${trimmed}</body></html>`;
}
type ChatPreviewBodyProps = {
target: ChatPreviewTarget | null;
previewText: string;
@@ -210,6 +237,7 @@ type ChatPreviewBodyProps = {
previewError: string;
previewContentType?: string;
maxMarkdownBlocks?: number;
renderHtmlAsFrame?: boolean;
};
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
@@ -238,6 +266,7 @@ export function ChatPreviewBody({
previewError,
previewContentType,
maxMarkdownBlocks,
renderHtmlAsFrame = false,
}: ChatPreviewBodyProps) {
if (!target) {
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
@@ -307,6 +336,16 @@ export function ChatPreviewBody({
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
const resolvedLanguage = resolveCodeLanguage(target, previewText);
if (renderHtmlAsFrame && resolvedLanguage === 'html' && canRenderFramePreview(target.url)) {
return (
<iframe
title={target.label}
srcDoc={buildHtmlFrameDocument(previewText, target.url)}
className="app-chat-panel__preview-frame"
/>
);
}
if (target.kind === 'diff' || resolvedLanguage === 'diff') {
return (
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">

View File

@@ -0,0 +1,26 @@
const AUTO_DETECTED_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s<>)\]]+|\/[A-Za-z0-9._~:/?#[\]@!$&'*+,;=%-]+)/g;
export function extractAutoDetectedPreviewUrls(text: string) {
const normalized = String(text ?? '');
const urls: string[] = [];
for (const match of normalized.matchAll(AUTO_DETECTED_PREVIEW_URL_PATTERN)) {
const value = match[0]?.trim();
if (!value) {
continue;
}
const startIndex = match.index ?? -1;
const previousChar = startIndex > 0 ? normalized[startIndex - 1] : '';
// Ignore HTML closing tags like </div> that were being misread as same-origin routes.
if (previousChar === '<') {
continue;
}
urls.push(value);
}
return urls;
}

View File

@@ -1,18 +1,26 @@
import {
ArrowLeftOutlined,
DownOutlined,
CheckSquareOutlined,
CompressOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
ArrowsAltOutlined,
EyeOutlined,
ExpandOutlined,
FileTextOutlined,
LinkOutlined,
PaperClipOutlined,
PlayCircleOutlined,
PlusOutlined,
SaveOutlined,
ShrinkOutlined,
UpOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import type { ChangeEvent, RefObject } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
import {
buildAutomationTypeOptions,
resolveAutomationTypeLabel,
@@ -27,7 +35,7 @@ import {
setupBoard,
updateBoardPost,
} from './api';
import type { BoardDraft, BoardPost } from './types';
import type { BoardAttachment, BoardDraft, BoardPost } from './types';
const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input;
@@ -36,15 +44,68 @@ const EMPTY_DRAFT: BoardDraft = {
id: null,
title: '',
content: '',
attachments: [],
automationType: 'none',
};
function createBoardAttachmentSessionId() {
const randomValue =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
return `board-draft-${randomValue}`;
}
function formatDateTime(value: string) {
return new Date(value).toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
});
}
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '0 B';
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${Math.round(value)} B`;
}
function mergeBoardAttachments(current: BoardAttachment[], next: BoardAttachment[]) {
const merged = [...current];
const existingPaths = new Set(current.map((item) => item.path));
next.forEach((item) => {
if (existingPaths.has(item.path)) {
return;
}
existingPaths.add(item.path);
merged.push(item);
});
return merged;
}
function resolveBoardAttachmentSessionId(
draftId: number | null,
draftAttachmentSessionIdRef: RefObject<string>,
) {
if (draftId) {
return `board-post-${draftId}`;
}
return draftAttachmentSessionIdRef.current;
}
async function copyText(value: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
@@ -122,6 +183,8 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe
export function BoardPage() {
const [messageApi, contextHolder] = message.useMessage();
const { automationTypes } = useAutomationTypeRegistry();
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const draftAttachmentSessionIdRef = useRef<string>(createBoardAttachmentSessionId());
const [items, setItems] = useState<BoardPost[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
@@ -129,34 +192,15 @@ export function BoardPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [attachmentUploading, setAttachmentUploading] = useState(false);
const [automationReceiving, setAutomationReceiving] = useState(false);
const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
const [contentExpanded, setContentExpanded] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 960px)');
const update = () => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setMobileDetailOpen(false);
}
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, []);
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [attachmentsExpanded, setAttachmentsExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -193,11 +237,32 @@ export function BoardPage() {
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 960px)');
const update = () => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setMobileView('edit');
}
setAttachmentsExpanded(!mediaQuery.matches);
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, []);
const selectedItem = useMemo(
() => items.find((item) => item.id === selectedId) ?? null,
[items, selectedId],
);
const showMobileDetailOnly = isMobileViewport && mobileDetailOpen;
const automationReceived = Boolean(selectedItem?.automationReceivedAt || selectedItem?.automationPlanItemId);
const isDraftLocked = automationReceived;
const draftDirty = Boolean(
@@ -218,6 +283,7 @@ export function BoardPage() {
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
[automationTypes, draft.automationType],
);
const isPaneMaximized = maximizedPane !== 'none';
const receivableIds = useMemo(
() =>
items
@@ -236,6 +302,7 @@ export function BoardPage() {
id: selectedItem.id,
title: selectedItem.title,
content: selectedItem.content,
attachments: selectedItem.attachments,
automationType: selectedItem.automationType,
});
setAutomationReceiveError(null);
@@ -252,10 +319,82 @@ export function BoardPage() {
}, [items]);
const handleCreateDraft = () => {
draftAttachmentSessionIdRef.current = createBoardAttachmentSessionId();
setSelectedId(null);
setDraft(EMPTY_DRAFT);
setAutomationReceiveError(null);
setMobileDetailOpen(isMobileViewport);
setMaximizedPane('none');
setMobileView('edit');
setDetailMode('detail');
};
const handleOpenDetail = (itemId: number) => {
setSelectedId(itemId);
setAutomationReceiveError(null);
setMaximizedPane('none');
setMobileView('edit');
setDetailMode('detail');
};
const handleCloseDetail = () => {
setAutomationReceiveError(null);
setMaximizedPane('none');
setDetailMode('list');
};
const handleAttachmentFilesPicked = async (files: File[]) => {
if (files.length === 0 || attachmentUploading || isDraftLocked) {
return;
}
setAttachmentUploading(true);
try {
const sessionId = resolveBoardAttachmentSessionId(draft.id, draftAttachmentSessionIdRef);
const uploadResults = await Promise.allSettled(files.map((file) => uploadChatComposerFile(sessionId, file)));
const uploadedItems: BoardAttachment[] = [];
const failedFileNames: string[] = [];
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
}
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
});
if (uploadedItems.length > 0) {
setDraft((previous) => ({
...previous,
attachments: mergeBoardAttachments(previous.attachments, uploadedItems),
}));
messageApi.success(`첨부 파일 ${uploadedItems.length}건을 추가했습니다.`);
}
if (failedFileNames.length > 0) {
messageApi.error(`업로드 실패: ${failedFileNames.join(', ')}`);
}
} finally {
setAttachmentUploading(false);
}
};
const handleAttachmentInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
void handleAttachmentFilesPicked(files);
};
const handleRemoveAttachment = (attachmentId: string) => {
if (isDraftLocked) {
return;
}
setDraft((previous) => ({
...previous,
attachments: previous.attachments.filter((attachment) => attachment.id !== attachmentId),
}));
};
const handleSave = async () => {
@@ -293,7 +432,7 @@ export function BoardPage() {
return [savedItem, ...filtered];
});
setSelectedId(savedItem.id);
setMobileDetailOpen(isMobileViewport);
setDetailMode('detail');
messageApi.success(draft.id ? '게시글을 수정했습니다.' : '게시글을 등록했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '게시글 저장에 실패했습니다.');
@@ -315,7 +454,7 @@ export function BoardPage() {
setItems((previous) => previous.filter((item) => item.id !== draft.id));
setSelectedId((previous) => (previous === draft.id ? null : previous));
setDraft(EMPTY_DRAFT);
setMobileDetailOpen(false);
setDetailMode('list');
messageApi.success('게시글을 삭제했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '게시글 삭제에 실패했습니다.');
@@ -441,52 +580,56 @@ export function BoardPage() {
};
return (
<Space direction="vertical" size={16} className="board-page">
<div
className={`board-page${detailMode === 'detail' ? ' board-page--detail' : ''}${
isPaneMaximized ? ' board-page--pane-maximized' : ''
}`}
>
{contextHolder}
<Card className="board-page__card" bordered={false}>
<Flex justify="space-between" align="center" gap={16} wrap>
<div>
<Title level={4} className="board-page__title">
Plan
</Title>
<Paragraph className="board-page__copy">
DB에 .
</Paragraph>
</div>
<Space wrap>
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
disabled={isDraftLocked}
onClick={() => {
void handleSave();
}}
>
</Button>
</Space>
</Flex>
</Card>
<input
ref={attachmentInputRef}
type="file"
multiple
className="board-page__hidden-file-input"
onChange={handleAttachmentInputChange}
/>
<Space direction="vertical" size={16} className="board-page__stack">
{detailMode === 'list' ? (
<Card className="board-page__card board-page__overview-card" bordered={false}>
<Flex justify="space-between" align="center" gap={16} wrap>
<div>
<Title level={4} className="board-page__title">
</Title>
<Paragraph className="board-page__copy">
, , .
</Paragraph>
</div>
<Space wrap>
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
</Button>
</Space>
</Flex>
</Card>
) : null}
{errorMessage ? (
{errorMessage && detailMode === 'list' ? (
<Card className="board-page__card" bordered={false}>
<Text type="danger">{errorMessage}</Text>
</Card>
) : null}
<div className="board-page__grid">
{detailMode === 'list' ? (
<Card
title={`게시글 목록 (${items.length})`}
className={`board-page__card board-page__list-card${
showMobileDetailOnly ? ' board-page__list-card--mobile-hidden' : ''
}`}
className="board-page__card board-page__list-card"
bordered={false}
extra={
<Space size={8} wrap>
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
</Button>
{loading ? <Spin size="small" /> : null}
<Text type="secondary" className="board-page__bulk-count">
{checkedReceivableCount}
@@ -528,15 +671,12 @@ export function BoardPage() {
<List
dataSource={items}
renderItem={(item) => (
<List.Item
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
onClick={() => {
setSelectedId(item.id);
if (isMobileViewport) {
setMobileDetailOpen(true);
}
}}
>
<List.Item
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
onClick={() => {
handleOpenDetail(item.id);
}}
>
<List.Item.Meta
avatar={
<Checkbox
@@ -562,6 +702,7 @@ export function BoardPage() {
</Flex>
<Space size={6} wrap>
{item.id === dirtyDraftId ? <Tag color="warning"> </Tag> : null}
{item.attachments.length ? <Tag color="blue"> {item.attachments.length}</Tag> : null}
<Tag color={item.automationReceivedAt || item.automationPlanItemId ? 'processing' : 'default'}>
{item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'}
</Tag>
@@ -584,151 +725,335 @@ export function BoardPage() {
<Empty description="등록된 게시글이 없습니다." />
)}
</Card>
<div
className={`board-page__editor-column${
isMobileViewport && !mobileDetailOpen ? ' board-page__editor-column--mobile-hidden' : ''
}`}
>
) : (
<div className="board-page__editor-column">
<Card
title={draft.id ? `게시글 #${draft.id}` : '새 게시글'}
className="board-page__card board-page__editor-card"
className={`board-page__card board-page__editor-card${isPaneMaximized ? ' board-page__editor-card--pane-maximized' : ''}`}
bordered={false}
extra={
<Space wrap>
{isMobileViewport && mobileDetailOpen ? (
<Button
icon={<ArrowLeftOutlined />}
onClick={() => {
setMobileDetailOpen(false);
}}
>
</Button>
) : null}
<Space wrap className="board-page__header-actions">
<Button icon={<ArrowLeftOutlined />} aria-label="목록으로" title="목록으로" onClick={handleCloseDetail} />
<Button icon={<PlusOutlined />} onClick={handleCreateDraft} aria-label="새 글" title="새 글" />
<Button
type="primary"
icon={<SaveOutlined />}
aria-label="저장"
title="저장"
loading={saving}
disabled={isDraftLocked}
onClick={() => {
void handleSave();
}}
/>
{draft.id ? <Tag color={automationStatus.color}>{automationStatus.label}</Tag> : null}
{draft.id && selectedItem?.automationPlanItemId ? (
<Button
icon={<LinkOutlined />}
aria-label="연결 자동화 열기"
title="연결 자동화 열기"
href={`/plans/all?planId=${selectedItem.automationPlanItemId}`}
target="_blank"
rel="noreferrer"
>
</Button>
/>
) : null}
{draft.id ? (
<Button
icon={<PlayCircleOutlined />}
aria-label={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
title={automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
loading={automationReceiving}
disabled={automationReceived && !automationReceiveError}
onClick={() => {
void handleAutomationReceive();
}}
>
{automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
</Button>
) : null}
{draft.id ? (
<Button
danger
icon={<DeleteOutlined />}
loading={deleting}
disabled={isDraftLocked}
onClick={() => {
void handleDelete();
}}
>
</Button>
/>
) : null}
<Button
danger
icon={<DeleteOutlined />}
aria-label="삭제"
title="삭제"
loading={deleting}
disabled={!draft.id || isDraftLocked}
onClick={() => {
void handleDelete();
}}
/>
</Space>
}
>
<Space direction="vertical" size={16} className="board-page__editor">
<Input
size="large"
placeholder="제목을 입력하세요"
value={draft.title}
readOnly={isDraftLocked}
onChange={(event) => {
setDraft((previous) => ({
...previous,
title: event.target.value,
}));
}}
/>
<Flex gap={8} wrap>
<Tag color={automationStatus.color}>{automationStatus.label}</Tag>
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
</Flex>
<div className="board-page__automation-field">
<Text strong> </Text>
{automationReceived ? (
<div className="board-page__automation-readonly" aria-readonly="true">
<Text>{automationTypeLabel}</Text>
<Tag color="processing"> </Tag>
<div className="board-page__editor">
{errorMessage ? <Text type="danger">{errorMessage}</Text> : null}
<div className="board-page__editor-scroll">
<div className={`board-page__meta-stack${isPaneMaximized ? ' board-page__meta-stack--hidden' : ''}`}>
<div className="board-page__hero">
<div className="board-page__hero-main">
<div className="board-page__field-label-row">
<Text strong> </Text>
<Flex gap={8} wrap>
<Tag color={automationStatus.color}>{automationStatus.label}</Tag>
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
</Flex>
</div>
<Input
size="large"
placeholder="예: 작업요청 입력 폼을 전면 개편하고 첨부 자동 전달 연결"
value={draft.title}
readOnly={isDraftLocked}
onChange={(event) => {
setDraft((previous) => ({
...previous,
title: event.target.value,
}));
}}
/>
</div>
<div className="board-page__hero-side">
<div className="board-page__automation-field">
<div className="board-page__field-label-row">
<Text strong> </Text>
{automationReceived ? <Tag color="processing"> </Tag> : null}
</div>
{automationReceived ? (
<div className="board-page__automation-readonly" aria-readonly="true">
<Text>{automationTypeLabel}</Text>
</div>
) : (
<Select
className="board-page__automation-select"
value={draft.automationType}
options={automationTypeOptions}
popupClassName="board-page__automation-select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={isDraftLocked}
onChange={(automationType) => {
setDraft((previous) => ({
...previous,
automationType,
}));
}}
/>
)}
</div>
</div>
</div>
) : (
<Select
className="board-page__automation-select"
value={draft.automationType}
options={automationTypeOptions}
popupClassName="board-page__automation-select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={isDraftLocked}
onChange={(automationType) => {
setDraft((previous) => ({
...previous,
automationType,
}));
}}
/>
)}
<div className="board-page__attachment-panel">
<Flex justify="space-between" align="center" gap={12} wrap>
<div>
<div className="board-page__field-label-row">
<Text strong> </Text>
<Tag color={draft.attachments.length ? 'blue' : 'default'}>
{draft.attachments.length}
</Tag>
</div>
<Text type="secondary"> .</Text>
</div>
<Space size={8}>
<Button
type="text"
icon={attachmentsExpanded ? <UpOutlined /> : <DownOutlined />}
aria-label={attachmentsExpanded ? '첨부 파일 접기' : '첨부 파일 펼치기'}
title={attachmentsExpanded ? '첨부 파일 접기' : '첨부 파일 펼치기'}
onClick={() => {
setAttachmentsExpanded((current) => !current);
}}
/>
<Button
icon={<UploadOutlined />}
aria-label="파일 추가"
title="파일 추가"
loading={attachmentUploading}
disabled={isDraftLocked}
onClick={() => {
attachmentInputRef.current?.click();
}}
/>
</Space>
</Flex>
{attachmentsExpanded ? draft.attachments.length ? (
<div className="board-page__attachment-grid">
{draft.attachments.map((attachment) => (
<div key={attachment.id} className="board-page__attachment-card">
<Flex justify="space-between" align="start" gap={12}>
<Flex vertical gap={6} className="board-page__attachment-copy">
<Space size={8} wrap>
<PaperClipOutlined className="board-page__attachment-icon" />
<Text strong ellipsis={{ tooltip: attachment.name }}>
{attachment.name}
</Text>
</Space>
<Text type="secondary">{formatBytes(attachment.size)}</Text>
<Text type="secondary" className="board-page__attachment-path">
{attachment.path}
</Text>
</Flex>
<Space size={6}>
<Button
size="small"
icon={<LinkOutlined />}
href={attachment.publicUrl}
target="_blank"
rel="noreferrer"
/>
<Button
size="small"
icon={<CloseOutlined />}
disabled={isDraftLocked}
onClick={() => {
handleRemoveAttachment(attachment.id);
}}
/>
</Space>
</Flex>
</div>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="첨부 파일이 없습니다." />
) : null}
</div>
</div>
<div className="board-page__markdown-field">
<Text strong className={`board-page__field-label${isPaneMaximized ? ' board-page__field-label--hidden' : ''}`}>
</Text>
<div className="board-page__markdown-editor">
<div className="board-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="board-page__mobile-toggle"
options={[
{ label: '편집', value: 'edit', icon: <FileTextOutlined /> },
{ label: '미리보기', value: 'preview', icon: <EyeOutlined /> },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap className="board-page__desktop-toolbar">
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
title={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
/>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
/>
</Space>
)}
</div>
<div
className={`board-page__preview-grid${
isPaneMaximized ? ' board-page__preview-grid--maximized' : ''
}`}
>
<div
className={`board-page__pane${
mobileView === 'preview' ? ' board-page__pane--mobile-hidden' : ''
}${maximizedPane === 'preview' ? ' board-page__pane--desktop-hidden' : ''}`}
>
<div className="board-page__pane-header">
<Text type="secondary"></Text>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label="본문 복사"
title="본문 복사"
onClick={() => {
void handleCopyContent();
}}
/>
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
title={maximizedPane === 'edit' ? '편집 축소' : '편집 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
/>
</Space>
</div>
<TextArea
value={draft.content}
placeholder={'# 제목\n\n마크다운 본문을 입력하세요.\n\n- 목록\n- 코드블록\n- 링크'}
readOnly={isDraftLocked}
onChange={(event) => {
setDraft((previous) => ({
...previous,
content: event.target.value,
}));
}}
className="board-page__textarea"
/>
</div>
<div
className={`board-page__pane${
mobileView === 'edit' ? ' board-page__pane--mobile-hidden' : ''
}${maximizedPane === 'edit' ? ' board-page__pane--desktop-hidden' : ''}`}
>
<div className="board-page__preview">
<div className="board-page__pane-header">
<Text type="secondary"></Text>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label="본문 복사"
title="본문 복사"
onClick={() => {
void handleCopyContent();
}}
/>
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
title={maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
/>
</Space>
</div>
<div className="board-page__preview-content">
{draft.content.trim() ? (
<MarkdownPreviewContent content={draft.content} />
) : (
<Empty description="미리보기할 본문이 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
</div>
</div>
</div>
{isDraftLocked ? (
<Text type="secondary"> .</Text>
) : null}
</div>
</div>
</div>
<div className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}>
{contentExpanded ? (
<Flex justify="space-between" align="center" gap={12} className="board-page__editor-toolbar">
<Text strong> </Text>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label="본문 복사"
title="본문 복사"
onClick={() => {
void handleCopyContent();
}}
>
</Button>
<Button
size="small"
icon={<CompressOutlined />}
aria-label="본문 최대화 해제"
onClick={() => {
setContentExpanded(false);
}}
>
</Button>
</Space>
</Flex>
) : null}
<Segmented
className="board-page__mobile-toggle"
options={[
{ label: '편집', value: 'edit', icon: <FileTextOutlined /> },
{ label: '미리보기', value: 'preview', icon: <EyeOutlined /> },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
}}
/>
<Flex justify="space-between" align="center" gap={8}>
<Text type="secondary"></Text>
{isPaneMaximized ? (
<div className="board-page__floating-toolbar">
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label="본문 복사"
title="본문 복사"
@@ -737,60 +1062,22 @@ export function BoardPage() {
}}
/>
<Button
icon={contentExpanded ? <CompressOutlined /> : <ExpandOutlined />}
aria-label={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
title={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
size="small"
icon={<ShrinkOutlined />}
aria-label="편집 보기로 복귀"
title="편집 보기로 복귀"
onClick={() => {
setContentExpanded((previous) => !previous);
setMaximizedPane('none');
}}
/>
</Space>
</Flex>
<div
className={`board-page__preview-grid${contentExpanded ? ' board-page__preview-grid--expanded' : ''}`}
>
<div
className={`board-page__pane${mobileView === 'preview' ? ' board-page__pane--mobile-hidden' : ''}${
contentExpanded ? ' board-page__pane--expanded' : ''
}`}
>
<TextArea
value={draft.content}
placeholder={'# 제목\n\n마크다운 본문을 입력하세요.\n\n- 목록\n- 코드블록\n- 링크'}
readOnly={isDraftLocked}
onChange={(event) => {
setDraft((previous) => ({
...previous,
content: event.target.value,
}));
}}
className={`board-page__textarea${contentExpanded ? ' board-page__textarea--expanded' : ''}`}
/>
</div>
<div
className={`board-page__pane${mobileView === 'edit' ? ' board-page__pane--mobile-hidden' : ''}${
contentExpanded ? ' board-page__pane--expanded' : ''
}`}
>
<div className={`board-page__preview${contentExpanded ? ' board-page__preview--expanded' : ''}`}>
<div className="board-page__preview-content">
{draft.content.trim() ? (
<MarkdownPreviewContent content={draft.content} />
) : (
<Empty description="미리보기할 본문이 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
</div>
</div>
</div>
{isDraftLocked ? (
<Text type="secondary"> .</Text>
) : null}
</div>
</Space>
) : null}
</div>
</Card>
</div>
</div>
</Space>
)}
</Space>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
import type { BoardAttachment, BoardAutomationType, BoardDraft, BoardPost } from './types';
class BoardApiError extends Error {
status: number;
@@ -17,6 +17,37 @@ function normalizeBoardAutomationType(value: unknown): BoardAutomationType {
return normalizeAutomationTypeId(value);
}
function normalizeBoardAttachment(item: unknown): BoardAttachment | null {
if (!item || typeof item !== 'object') {
return null;
}
const candidate = item as Partial<BoardAttachment>;
const id = String(candidate.id ?? '').trim();
const path = String(candidate.path ?? '').trim();
if (!id || !path) {
return null;
}
return {
id,
name: String(candidate.name ?? '').trim() || path.split('/').pop() || '첨부 파일',
path,
publicUrl: String(candidate.publicUrl ?? '').trim() || path,
size: Math.max(0, Number(candidate.size ?? 0) || 0),
mimeType: String(candidate.mimeType ?? '').trim() || 'application/octet-stream',
};
}
function normalizeBoardPost(item: BoardPost): BoardPost {
return {
...item,
automationType: normalizeBoardAutomationType(item.automationType),
attachments: Array.isArray(item.attachments) ? item.attachments.map(normalizeBoardAttachment).filter(Boolean) as BoardAttachment[] : [],
};
}
function resolveBoardApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
@@ -134,10 +165,7 @@ export async function setupBoard() {
export async function fetchBoardPosts() {
const response = await request<{ ok: boolean; items: BoardPost[] }>('/board/posts');
return response.items.map((item) => ({
...item,
automationType: normalizeBoardAutomationType(item.automationType),
}));
return response.items.map((item) => normalizeBoardPost(item));
}
export async function createBoardPost(draft: BoardDraft) {
@@ -146,14 +174,12 @@ export async function createBoardPost(draft: BoardDraft) {
body: JSON.stringify({
title: draft.title,
content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType,
}),
});
return {
...response.item,
automationType: normalizeBoardAutomationType(response.item.automationType),
};
return normalizeBoardPost(response.item);
}
export async function updateBoardPost(draft: BoardDraft) {
@@ -166,14 +192,12 @@ export async function updateBoardPost(draft: BoardDraft) {
body: JSON.stringify({
title: draft.title,
content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType,
}),
});
return {
...response.item,
automationType: normalizeBoardAutomationType(response.item.automationType),
};
return normalizeBoardPost(response.item);
}
export async function receiveBoardPostAutomation(id: number) {
@@ -188,10 +212,7 @@ export async function receiveBoardPostAutomation(id: number) {
});
return {
item: {
...response.item,
automationType: normalizeBoardAutomationType(response.item.automationType),
},
item: normalizeBoardPost(response.item),
planItemId: response.planItemId,
alreadyReceived: response.alreadyReceived,
};

View File

@@ -1,10 +1,20 @@
export type BoardAutomationType = string;
export type BoardAttachment = {
id: string;
name: string;
path: string;
publicUrl: string;
size: number;
mimeType: string;
};
export type BoardPost = {
id: number;
title: string;
content: string;
preview: string;
attachments: BoardAttachment[];
automationType: BoardAutomationType;
automationPlanItemId: number | null;
automationReceivedAt: string | null;
@@ -16,5 +26,6 @@ export type BoardDraft = {
id: number | null;
title: string;
content: string;
attachments: BoardAttachment[];
automationType: BoardAutomationType;
};

View File

@@ -1022,7 +1022,44 @@ button,
.board-page {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.board-page--detail {
container-type: inline-size;
}
.board-page__stack.ant-space,
.board-page__stack.ant-space > .ant-space-item {
width: 100%;
}
.board-page__stack.ant-space {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.board-page--detail .board-page__stack.ant-space > .ant-space-item,
.board-page--detail .board-page__editor-column,
.board-page--detail .board-page__editor-card,
.board-page--detail .board-page__editor-card .ant-card-body,
.board-page--detail .board-page__editor,
.board-page--detail .board-page__editor-scroll,
.board-page--detail .board-page__markdown-field,
.board-page--detail .board-page__markdown-editor,
.board-page--detail .board-page__preview-grid,
.board-page--detail .board-page__pane,
.board-page--detail .board-page__preview {
flex: 1 1 auto;
min-height: 0;
}
.board-page__card {
@@ -1031,6 +1068,18 @@ button,
box-shadow: none;
}
.board-page .ant-card,
.board-page .ant-card-body,
.board-page__card .ant-card-body {
min-width: 0;
}
.board-page .ant-card,
.board-page__card {
display: flex;
flex-direction: column;
}
.board-page__title.ant-typography {
margin-bottom: 6px;
}
@@ -1039,17 +1088,12 @@ button,
margin-bottom: 0;
}
.board-page__grid {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 16px;
min-width: 0;
}
.board-page__editor-column {
position: relative;
z-index: 2;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.board-page__list-card .ant-card-body,
@@ -1057,6 +1101,16 @@ button,
min-width: 0;
}
.board-page__list-card .ant-card-head,
.board-page__editor-card .ant-card-head {
padding-inline: 20px;
}
.board-page__list-card .ant-card-body,
.board-page__editor-card .ant-card-body {
padding: 20px;
}
.board-page__list-card--mobile-hidden,
.board-page__editor-column--mobile-hidden {
display: none;
@@ -1065,7 +1119,18 @@ button,
.board-page__editor-card,
.board-page__editor-card .ant-card-body {
position: relative;
overflow: visible;
}
.board-page__editor-card .ant-card-body {
height: 100%;
min-height: calc(100vh - 240px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.board-page__editor-card--pane-maximized .ant-card-body {
padding-bottom: 10px;
}
.board-page__list-item {
@@ -1103,18 +1168,42 @@ button,
font-weight: 600;
}
.board-page__editor.ant-space {
.board-page__editor {
width: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.board-page__editor.ant-space > .ant-space-item {
.board-page__editor-scroll {
width: 100%;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
overflow: auto;
padding-bottom: 8px;
}
.board-page__editor.ant-space > .ant-space-item:last-child {
min-height: 0;
.board-page__header-actions {
align-items: center;
justify-content: flex-end;
width: 100%;
}
.board-page__meta-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.board-page__meta-stack--hidden {
display: none;
}
.board-page__automation-field {
@@ -1124,10 +1213,72 @@ button,
gap: 8px;
}
.board-page__automation-readonly {
.board-page__hero {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr);
gap: 16px;
align-items: end;
}
.board-page__hero-main,
.board-page__hero-side,
.board-page__attachment-panel {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.board-page__field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.board-page__attachment-panel {
padding: 16px 18px;
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(244, 248, 255, 0.98) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.88),
0 14px 32px rgba(15, 23, 42, 0.05);
}
.board-page__attachment-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.board-page__attachment-card {
min-width: 0;
padding: 14px;
border: 1px solid rgba(22, 93, 255, 0.14);
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
}
.board-page__attachment-copy {
min-width: 0;
flex: 1 1 auto;
}
.board-page__attachment-icon {
color: #1677ff;
}
.board-page__attachment-path {
word-break: break-all;
}
.board-page__automation-readonly {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
min-height: 40px;
padding: 9px 12px;
@@ -1170,29 +1321,47 @@ button,
display: none;
}
.board-page__editor-frame {
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
min-height: 0;
.board-page__hidden-file-input {
display: none;
}
.board-page__editor-frame--expanded {
position: fixed;
inset: 0;
z-index: 1300;
.board-page__markdown-field {
width: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
background: #fff;
gap: 6px;
overflow: hidden;
}
.board-page__field-label {
line-height: 1.2;
}
.board-page__field-label--hidden {
display: none;
}
.board-page__markdown-editor {
width: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.board-page__editor-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.board-page__desktop-toolbar {
margin-left: auto;
}
.board-page__preview-grid {
@@ -1201,63 +1370,65 @@ button,
gap: 16px;
width: 100%;
min-width: 0;
min-height: 0;
}
.board-page__preview-grid--expanded {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.board-page__preview-grid--maximized {
grid-template-columns: minmax(0, 1fr);
}
.board-page__pane {
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.board-page__pane--expanded {
.board-page__pane--desktop-hidden {
display: none;
}
.board-page__pane-header {
display: flex;
flex: 1 1 0;
width: 100%;
min-height: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.board-page__textarea.ant-input {
flex: 1 1 auto;
height: 100%;
min-height: 520px;
font-family:
'JetBrains Mono', 'D2Coding', 'Fira Code', Consolas, monospace;
line-height: 1.6;
resize: vertical;
}
.board-page__textarea--expanded.ant-input {
min-height: calc(100vh - 140px);
height: calc(100vh - 140px);
flex: 1 1 auto;
resize: none;
}
.board-page__preview {
display: flex;
flex-direction: column;
flex: 1 1 auto;
height: 100%;
min-height: 520px;
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 14px;
background: #ffffff;
padding: 18px;
overflow: auto;
overflow: hidden;
}
.board-page__preview-content {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.board-page__preview--expanded {
min-height: calc(100vh - 140px);
height: calc(100vh - 140px);
flex: 1 1 auto;
min-width: 0;
overflow: auto;
}
.board-page__loading {
@@ -1266,6 +1437,16 @@ button,
min-height: 220px;
}
.board-page__floating-toolbar {
position: sticky;
bottom: 0;
z-index: 3;
display: flex;
justify-content: flex-end;
padding-top: 8px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.96) 28%);
}
.release-pending-main-modal .ant-modal-content {
border-radius: 24px;
}
@@ -1493,6 +1674,48 @@ button,
}
@media (max-width: 960px) {
.board-page {
flex: 1 1 auto;
min-height: 0;
}
.board-page .ant-card-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.board-page--detail .board-page__editor-card .ant-card-body {
min-height: 0;
}
.board-page__overview-card {
display: none;
}
.board-page__list-card .ant-card-head,
.board-page__editor-card .ant-card-head {
min-height: 48px;
padding-inline: 10px;
}
.board-page__list-card .ant-card-head-title,
.board-page__list-card .ant-card-extra,
.board-page__editor-card .ant-card-head-title,
.board-page__editor-card .ant-card-extra,
.board-page__list-card .ant-card-body,
.board-page__editor-card .ant-card-body {
padding: 7px 10px;
}
.board-page__list-card .ant-card-head-title,
.board-page__list-card .ant-card-extra,
.board-page__editor-card .ant-card-head-title,
.board-page__editor-card .ant-card-extra {
padding-top: 6px;
padding-bottom: 6px;
}
.history-page__filter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1505,10 +1728,25 @@ button,
grid-template-columns: 1fr;
}
.board-page__grid {
.board-page__hero {
grid-template-columns: 1fr;
}
.board-page__attachment-grid {
grid-template-columns: 1fr;
}
.board-page__editor-column,
.board-page__editor,
.board-page__editor-scroll,
.board-page__markdown-field,
.board-page__markdown-editor,
.board-page__preview-grid,
.board-page__pane,
.board-page__preview {
min-height: 0;
}
.history-page__list-card .ant-card-body,
.history-page__detail-card .ant-card-body,
.chat-source-changes-page__list-card .ant-card-body,
@@ -1518,13 +1756,7 @@ button,
.board-page__textarea.ant-input,
.board-page__preview {
min-height: 360px;
}
.board-page__textarea--expanded.ant-input,
.board-page__preview--expanded {
min-height: calc(100vh - 148px);
height: calc(100vh - 148px);
min-height: 0;
}
.release-pending-main-modal .ant-modal {
@@ -1554,20 +1786,28 @@ button,
grid-template-columns: 1fr;
}
.board-page__grid {
grid-template-columns: 1fr;
}
.board-page__mobile-toggle {
display: inline-flex;
}
.board-page__preview-grid {
grid-template-columns: 1fr;
.board-page__editor-toolbar {
flex-wrap: wrap;
justify-content: space-between;
gap: 6px;
}
.board-page__editor-frame--expanded {
padding: 16px;
.board-page__desktop-toolbar {
display: none;
}
.board-page__field-label-row {
align-items: flex-start;
flex-direction: column;
}
.board-page__preview-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.board-page__automation-readonly {
@@ -1579,6 +1819,36 @@ button,
display: none;
}
.board-page__textarea.ant-input {
height: 100% !important;
min-height: 0;
}
.board-page__textarea.ant-input textarea {
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
overflow: auto !important;
resize: none;
}
.board-page__preview {
padding: 8px 10px;
}
.board-page__preview-content {
min-height: 0;
}
.board-page__header-actions {
gap: 4px;
}
.board-page__pane-header .ant-space {
flex-wrap: wrap;
justify-content: flex-end;
}
.release-review-page__toolbar {
align-items: stretch;
}