feat: refine codex live chat context flows
This commit is contained in:
@@ -1,61 +1,25 @@
|
||||
# Components Package Guide
|
||||
# Components
|
||||
|
||||
`src/components`는 앱 전용 화면이 아니라 여러 화면과 샘플, 문서에서 공통 재사용할 UI 조각을 두는 패키지입니다. 컴포넌트 추가나 수정 시 이 문서를 기본 규약으로 사용합니다.
|
||||
`src/components`는 여러 화면에서 재사용하는 공통 UI 패키지입니다.
|
||||
|
||||
## 목적
|
||||
|
||||
- 화면 조합에 재사용되는 공통 UI를 보관합니다.
|
||||
- 라이브러리 export 대상과 앱 내부 재사용 대상을 같은 폴더 기준으로 관리합니다.
|
||||
- 컴포넌트 문서(`docs/components`)와 샘플(`samples`)의 기준 소스 역할을 합니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출, DB 접근, 라우팅, 화면 전용 상태, 비즈니스 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트 설계는 최대한 멍청하게 유지합니다. 직관적인 props를 받고, 그 props에 따라 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 상태 orchestration은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 어디에서나 재사용될 수 있으므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서 확장하거나 보완합니다.
|
||||
|
||||
## 현재 하위 구조
|
||||
|
||||
- `common`: 범용 보조 컴포넌트
|
||||
- `dashboard`: 진행률, 다중 progress 등 대시보드 계열 공통 UI
|
||||
- `dataListTable`, `dataStatePanel`, `embeddedMap`, `emptyIllustrationCard`, `evidenceAttachmentStrip`, `formField`, `markdownPreview`, `navigation`, `previewer`, `processFlow`, `queryFilterBuilder`, `search`, `stateKit`, `status-badge`, `stepper`, `timelinePanel`, `window`: 독립 재사용 가능한 컴포넌트 패키지
|
||||
- `inputs`: 입력 계열 공통 UI
|
||||
- `primitives`: 가장 작은 입력 단위
|
||||
- `specialized`: 목적이 뚜렷한 파생 입력
|
||||
- `composite`: 여러 입력을 묶은 조합형 UI
|
||||
- `select`, `checkCombo`, `popup`: plugin 확장과 샘플이 포함된 입력 패키지
|
||||
|
||||
## 폴더 구성 규약
|
||||
|
||||
컴포넌트 패키지는 가능하면 아래 구조를 따릅니다.
|
||||
## 구조
|
||||
|
||||
```text
|
||||
component-name/
|
||||
├─ ComponentName.tsx
|
||||
├─ ComponentName.css
|
||||
├─ index.ts
|
||||
├─ types/ # 외부 노출 타입 또는 내부 분리 타입
|
||||
├─ plugins/ # plugin factory 또는 preset
|
||||
└─ samples/ # Docs/APIs 화면에서 쓰는 예제
|
||||
src/components
|
||||
├─ common
|
||||
├─ inputs
|
||||
├─ markdownPreview
|
||||
├─ navigation
|
||||
├─ previewer
|
||||
└─ ...
|
||||
```
|
||||
|
||||
- 진입점은 항상 해당 폴더의 `index.ts`로 둡니다.
|
||||
- 외부에서 직접 import 해야 하는 타입은 `index.ts` 또는 `types/index.ts`를 통해 다시 export 합니다.
|
||||
- CSS가 필요하면 컴포넌트 폴더 내부에 함께 둡니다.
|
||||
- 복잡한 로직이 생기면 `types`, `plugins`, `samples`처럼 역할별 하위 폴더로 분리합니다.
|
||||
- `common`: 범용 보조 UI
|
||||
- `inputs`: 입력 계열 컴포넌트
|
||||
- 그 외 폴더: 독립 재사용 컴포넌트 패키지
|
||||
|
||||
## 구현 규약
|
||||
## 기준
|
||||
|
||||
- 공통 패키지에는 프로젝트 화면에 종속된 상태나 라우팅 의존을 넣지 않습니다.
|
||||
- 컴포넌트 이름, 파일명, export 이름은 PascalCase를 유지합니다. 폴더명은 기존 저장소 스타일대로 kebab-case 또는 lowerCamelCase를 따릅니다.
|
||||
- 라이브러리로 공개할 컴포넌트는 최종적으로 `src/index.ts`에서 다시 export 되어야 합니다.
|
||||
- 샘플이 필요한 컴포넌트는 `samples/Sample.tsx`를 기본 진입 예제로 두고, 변형 예제는 같은 폴더에 추가합니다.
|
||||
- plugin 확장형 컴포넌트는 `plugins/*.plugin.ts` 또는 `plugins/index.ts`에서 생성 함수를 모읍니다.
|
||||
- 공통 타입은 컴포넌트 폴더 안에서 우선 관리하고, 여러 컴포넌트가 공유할 때만 상위 공통 타입으로 승격합니다.
|
||||
|
||||
## 문서 규약
|
||||
|
||||
- 화면 사용법과 제약은 `docs/components/*.md`에 문서화합니다.
|
||||
- 새 컴포넌트를 추가하면 최소한 목적, 주요 props, 샘플 위치, plugin 여부를 문서에 남깁니다.
|
||||
- 패키지 구조나 규약이 바뀌면 이 문서와 해당 컴포넌트 문서를 함께 갱신합니다.
|
||||
- 화면 전용 상태와 비즈니스 로직은 넣지 않습니다.
|
||||
- 외부 진입점은 각 폴더의 `index.ts`를 사용합니다.
|
||||
- 복잡도가 커지면 `types`, `plugins`, `samples`로 분리합니다.
|
||||
|
||||
157
src/components/chatPromptCard/samples/Sample.tsx
Normal file
157
src/components/chatPromptCard/samples/Sample.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Flex, Typography } from 'antd';
|
||||
import type { SampleMeta } from '../../../widgets/core';
|
||||
import { ChatPromptCard } from '../../../app/main/mainChatPanel/ChatPromptCard';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'chat-prompt-card',
|
||||
componentId: 'chat-prompt-card',
|
||||
title: 'Chat Prompt Card',
|
||||
description: '채팅방 안에서 선택형 시안과 시간초과 자동선택 결과를 읽기 전용으로 보여주는 prompt 카드입니다.',
|
||||
category: 'Chat',
|
||||
kind: 'feature',
|
||||
variantLabel: 'Prompt',
|
||||
order: 95,
|
||||
features: ['docs'],
|
||||
};
|
||||
|
||||
export function Sample() {
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<Paragraph>
|
||||
첫 카드는 사용자가 직접 선택할 수 있는 상태이고, 두 번째 카드는 시간 초과 뒤 자동 선택된 결과를 읽기 전용으로 보여줍니다.
|
||||
</Paragraph>
|
||||
<ChatPromptCard
|
||||
target={{
|
||||
type: 'prompt',
|
||||
title: 'UI 수정 흐름 선택',
|
||||
description: '단계형 prompt를 통해 시안과 후속 작업 범위를 순서대로 정합니다.',
|
||||
submitLabel: '흐름 전달',
|
||||
mode: 'queue',
|
||||
options: [
|
||||
{
|
||||
label: '기본안',
|
||||
value: 'default',
|
||||
description: 'steps가 없을 때를 위한 fallback 옵션',
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
key: 'layout',
|
||||
title: '시안 선택',
|
||||
description: '아래 시안 중 하나를 골라 기본 레이아웃을 정합니다.',
|
||||
options: [
|
||||
{
|
||||
label: 'A안',
|
||||
value: 'option-a',
|
||||
description: '상단 헤더 강조와 큰 썸네일 중심 레이아웃',
|
||||
preview: {
|
||||
type: 'image',
|
||||
url: 'https://images.unsplash.com/photo-1519389950473-47ba0277781c?auto=format&fit=crop&w=900&q=80',
|
||||
alt: '대시보드 와이어프레임 샘플',
|
||||
title: 'A안 시안',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'B안',
|
||||
value: 'option-b',
|
||||
description: '중간 요약 카드와 탭 전환 중심 레이아웃',
|
||||
preview: {
|
||||
type: 'markdown',
|
||||
title: 'B안 요약',
|
||||
content: '## B안 핵심\n- 상단에 상태 요약 카드\n- 중간에 탭 3개\n- 하단 액션은 최소화',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'C안',
|
||||
value: 'option-c',
|
||||
description: '하단 고정 액션과 짧은 설명 중심 레이아웃',
|
||||
preview: {
|
||||
type: 'html',
|
||||
title: 'C안 레이아웃',
|
||||
content:
|
||||
'<section style="font-family:system-ui;padding:16px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#fff;border-radius:16px"><h3 style="margin:0 0 8px">C안</h3><p style="margin:0 0 12px">하단 고정 액션과 짧은 설명 중심</p><div style="display:grid;gap:8px"><div style="height:64px;background:rgba(255,255,255,.14);border-radius:12px"></div><div style="height:64px;background:rgba(255,255,255,.14);border-radius:12px"></div></div></section>',
|
||||
},
|
||||
},
|
||||
],
|
||||
responseTemplate: '{{selection_label}} 시안을 기본 레이아웃으로 채택했습니다.',
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
title: '후속 작업 범위',
|
||||
description: '선택 시안 기준으로 어떤 후속 작업을 이어갈지 고릅니다.',
|
||||
multiple: true,
|
||||
optional: true,
|
||||
options: [
|
||||
{
|
||||
label: '모바일 여백 정리',
|
||||
value: 'mobile-spacing',
|
||||
description: '모바일 화면 여백과 버튼 배치를 먼저 다듬습니다.',
|
||||
},
|
||||
{
|
||||
label: '상태 요약 추가',
|
||||
value: 'summary-card',
|
||||
description: '상단 요약 카드와 상태 문구를 함께 추가합니다.',
|
||||
},
|
||||
{
|
||||
label: '미리보기 문서 생성',
|
||||
value: 'preview-doc',
|
||||
description: '세션 리소스에 HTML/Markdown 시안을 같이 생성합니다.',
|
||||
},
|
||||
],
|
||||
freeTextLabel: '세부 요청',
|
||||
freeTextPlaceholder: '예: 첫 단계는 시안만 정하고 구현은 다음 응답에서 이어가세요.',
|
||||
},
|
||||
],
|
||||
responseTemplate: '사용자가 다음 단계형 흐름을 선택했습니다.\n{{step_summaries}}\n{{custom_text_block}}',
|
||||
}}
|
||||
onSubmit={async () => true}
|
||||
/>
|
||||
<ChatPromptCard
|
||||
target={{
|
||||
type: 'prompt',
|
||||
title: '작업 결과안 자동 선택',
|
||||
description: '응답 시간이 지나 시스템이 기본안을 선택했습니다.',
|
||||
readOnly: true,
|
||||
selectedValues: ['result-b'],
|
||||
resolvedBy: 'timeout',
|
||||
resultText: 'B안이 기본 시안으로 채택되었고, 다음 응답부터 이 흐름을 기준으로 이어갑니다.',
|
||||
options: [
|
||||
{
|
||||
label: '결과안 A',
|
||||
value: 'result-a',
|
||||
description: '카드형 설명을 크게 보여주는 결과안',
|
||||
preview: {
|
||||
type: 'image',
|
||||
url: 'https://images.unsplash.com/photo-1558655146-9f40138edfeb?auto=format&fit=crop&w=900&q=80',
|
||||
alt: '결과안 A 샘플',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '결과안 B',
|
||||
value: 'result-b',
|
||||
description: '선택 요약과 다음 액션을 한 줄로 정리한 결과안',
|
||||
preview: {
|
||||
type: 'markdown',
|
||||
content: '### 결과안 B\n선택 요약과 다음 액션을 한 줄로 정리합니다.',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '결과안 C',
|
||||
value: 'result-c',
|
||||
description: '추가 제안 링크를 함께 노출하는 결과안',
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/docs/index.md',
|
||||
title: '문서 리소스 예시',
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSubmit={async () => false}
|
||||
readOnly
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
EvidenceAttachmentPreviewBodyProps,
|
||||
EvidenceAttachmentStripProps,
|
||||
} from './types';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import './EvidenceAttachmentStrip.css';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
@@ -108,25 +109,7 @@ function getAttachmentTypeIcon(kind: EvidenceAttachmentKind): ReactNode {
|
||||
|
||||
async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
|
||||
const copyValue = attachment.copyValue ?? attachment.value;
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(copyValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('clipboard-unavailable');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = copyValue;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return copyTextToClipboard(copyValue);
|
||||
}
|
||||
|
||||
function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Empty, Segmented, Space, Tag, Typography, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import {
|
||||
CODEX_DIFF_STATUS_LABEL_MAP,
|
||||
CodexDiffBlock,
|
||||
@@ -100,30 +101,9 @@ export function CodexDiffPreviewer({
|
||||
const canShowDiff = Boolean(diffText);
|
||||
const resolvedMode = mode === 'auto' ? activeMode : mode;
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
async function handleCopy(content: string) {
|
||||
try {
|
||||
await copyText(content);
|
||||
await copyTextToClipboard(content);
|
||||
messageApi.success('복사했습니다.');
|
||||
} catch {
|
||||
messageApi.error('복사에 실패했습니다.');
|
||||
|
||||
@@ -169,17 +169,22 @@
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(22, 93, 255, 0.08);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.previewer-ui__markdown pre code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.previewer-ui__markdown pre {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
color: #dbe7ff;
|
||||
background: linear-gradient(180deg, #0f172a 0%, #111f39 100%);
|
||||
@@ -187,6 +192,10 @@
|
||||
}
|
||||
|
||||
.previewer-ui__editor {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
@@ -235,6 +244,10 @@
|
||||
}
|
||||
|
||||
.previewer-ui__editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button, Empty, Input, Select, message } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { InlineImage } from '../common/InlineImage';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import { CodexDiffBlock } from './CodexDiffBlock';
|
||||
import type { PreviewerUIProps } from './types';
|
||||
import { inferCodeLanguage, renderEditorBlock } from './renderers';
|
||||
@@ -121,27 +122,6 @@ function renderMarkdown(markdown: string) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('클립보드 API를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function downloadBlob(content: BlobPart, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('다운로드를 사용할 수 없습니다.');
|
||||
@@ -340,7 +320,7 @@ export function PreviewerUI({
|
||||
}
|
||||
|
||||
try {
|
||||
await copyText(resolvedCopyValue);
|
||||
await copyTextToClipboard(resolvedCopyValue);
|
||||
messageApi.success('복사했습니다.');
|
||||
} catch {
|
||||
messageApi.error('복사에 실패했습니다.');
|
||||
|
||||
Reference in New Issue
Block a user