feat: refine codex live chat context flows

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

View File

@@ -1,61 +1,25 @@
# Components Package Guide
# Components
`src/components`앱 전용 화면이 아니라 여러 화면과 샘플, 문서에서 공통 재사용할 UI 조각을 두는 패키지입니다. 컴포넌트 추가나 수정 시 이 문서를 기본 규약으로 사용합니다.
`src/components`여러 화면에서 재사용하는 공통 UI 패키지입니다.
## 목적
- 화면 조합에 재사용되는 공통 UI를 보관합니다.
- 라이브러리 export 대상과 앱 내부 재사용 대상을 같은 폴더 기준으로 관리합니다.
- 컴포넌트 문서(`docs/components`)와 샘플(`samples`)의 기준 소스 역할을 합니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출, DB 접근, 라우팅, 화면 전용 상태, 비즈니스 로직을 직접 넣지 않습니다.
- 컴포넌트 설계는 최대한 멍청하게 유지합니다. 직관적인 props를 받고, 그 props에 따라 직관적인 UI 동작만 수행합니다.
- 기능 처리와 상태 orchestration은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 어디에서나 재사용될 수 있으므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서 확장하거나 보완합니다.
## 현재 하위 구조
- `common`: 범용 보조 컴포넌트
- `dashboard`: 진행률, 다중 progress 등 대시보드 계열 공통 UI
- `dataListTable`, `dataStatePanel`, `embeddedMap`, `emptyIllustrationCard`, `evidenceAttachmentStrip`, `formField`, `markdownPreview`, `navigation`, `previewer`, `processFlow`, `queryFilterBuilder`, `search`, `stateKit`, `status-badge`, `stepper`, `timelinePanel`, `window`: 독립 재사용 가능한 컴포넌트 패키지
- `inputs`: 입력 계열 공통 UI
- `primitives`: 가장 작은 입력 단위
- `specialized`: 목적이 뚜렷한 파생 입력
- `composite`: 여러 입력을 묶은 조합형 UI
- `select`, `checkCombo`, `popup`: plugin 확장과 샘플이 포함된 입력 패키지
## 폴더 구성 규약
컴포넌트 패키지는 가능하면 아래 구조를 따릅니다.
## 구조
```text
component-name/
├─ ComponentName.tsx
├─ ComponentName.css
├─ index.ts
├─ types/ # 외부 노출 타입 또는 내부 분리 타입
├─ plugins/ # plugin factory 또는 preset
└─ samples/ # Docs/APIs 화면에서 쓰는 예제
src/components
├─ common
├─ inputs
├─ markdownPreview
├─ navigation
├─ previewer
└─ ...
```
- 진입점은 항상 해당 폴더의 `index.ts`로 둡니다.
- 외부에서 직접 import 해야 하는 타입은 `index.ts` 또는 `types/index.ts`를 통해 다시 export 합니다.
- CSS가 필요하면 컴포넌트 폴더 내부에 함께 둡니다.
- 복잡한 로직이 생기면 `types`, `plugins`, `samples`처럼 역할별 하위 폴더로 분리합니다.
- `common`: 범용 보조 UI
- `inputs`: 입력 계열 컴포넌트
- 그 외 폴더: 독립 재사용 컴포넌트 패키지
## 구현 규약
## 기준
- 공통 패키지에는 프로젝트 화면에 종속된 상태나 라우팅 의존을 넣지 않습니다.
- 컴포넌트 이름, 파일명, export 이름은 PascalCase를 유지합니다. 폴더명은 기존 저장소 스타일대로 kebab-case 또는 lowerCamelCase를 따릅니다.
- 라이브러리로 공개할 컴포넌트는 최종적으로 `src/index.ts`에서 다시 export 되어야 합니다.
- 샘플이 필요한 컴포넌트는 `samples/Sample.tsx`를 기본 진입 예제로 두고, 변형 예제는 같은 폴더에 추가합니다.
- plugin 확장형 컴포넌트는 `plugins/*.plugin.ts` 또는 `plugins/index.ts`에서 생성 함수를 모읍니다.
- 공통 타입은 컴포넌트 폴더 안에서 우선 관리하고, 여러 컴포넌트가 공유할 때만 상위 공통 타입으로 승격합니다.
## 문서 규약
- 화면 사용법과 제약은 `docs/components/*.md`에 문서화합니다.
- 새 컴포넌트를 추가하면 최소한 목적, 주요 props, 샘플 위치, plugin 여부를 문서에 남깁니다.
- 패키지 구조나 규약이 바뀌면 이 문서와 해당 컴포넌트 문서를 함께 갱신합니다.
- 화면 전용 상태와 비즈니스 로직은 넣지 않습니다.
- 외부 진입점은 각 폴더의 `index.ts`를 사용합니다.
- 복잡도가 커지면 `types`, `plugins`, `samples`로 분리합니다.

View File

@@ -0,0 +1,157 @@
import { Flex, Typography } from 'antd';
import type { SampleMeta } from '../../../widgets/core';
import { ChatPromptCard } from '../../../app/main/mainChatPanel/ChatPromptCard';
const { Paragraph } = Typography;
export const sampleMeta: SampleMeta = {
id: 'chat-prompt-card',
componentId: 'chat-prompt-card',
title: 'Chat Prompt Card',
description: '채팅방 안에서 선택형 시안과 시간초과 자동선택 결과를 읽기 전용으로 보여주는 prompt 카드입니다.',
category: 'Chat',
kind: 'feature',
variantLabel: 'Prompt',
order: 95,
features: ['docs'],
};
export function Sample() {
return (
<Flex vertical gap={16}>
<Paragraph>
, .
</Paragraph>
<ChatPromptCard
target={{
type: 'prompt',
title: 'UI 수정 흐름 선택',
description: '단계형 prompt를 통해 시안과 후속 작업 범위를 순서대로 정합니다.',
submitLabel: '흐름 전달',
mode: 'queue',
options: [
{
label: '기본안',
value: 'default',
description: 'steps가 없을 때를 위한 fallback 옵션',
},
],
steps: [
{
key: 'layout',
title: '시안 선택',
description: '아래 시안 중 하나를 골라 기본 레이아웃을 정합니다.',
options: [
{
label: 'A안',
value: 'option-a',
description: '상단 헤더 강조와 큰 썸네일 중심 레이아웃',
preview: {
type: 'image',
url: 'https://images.unsplash.com/photo-1519389950473-47ba0277781c?auto=format&fit=crop&w=900&q=80',
alt: '대시보드 와이어프레임 샘플',
title: 'A안 시안',
},
},
{
label: 'B안',
value: 'option-b',
description: '중간 요약 카드와 탭 전환 중심 레이아웃',
preview: {
type: 'markdown',
title: 'B안 요약',
content: '## B안 핵심\n- 상단에 상태 요약 카드\n- 중간에 탭 3개\n- 하단 액션은 최소화',
},
},
{
label: 'C안',
value: 'option-c',
description: '하단 고정 액션과 짧은 설명 중심 레이아웃',
preview: {
type: 'html',
title: 'C안 레이아웃',
content:
'<section style="font-family:system-ui;padding:16px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#fff;border-radius:16px"><h3 style="margin:0 0 8px">C안</h3><p style="margin:0 0 12px">하단 고정 액션과 짧은 설명 중심</p><div style="display:grid;gap:8px"><div style="height:64px;background:rgba(255,255,255,.14);border-radius:12px"></div><div style="height:64px;background:rgba(255,255,255,.14);border-radius:12px"></div></div></section>',
},
},
],
responseTemplate: '{{selection_label}} 시안을 기본 레이아웃으로 채택했습니다.',
},
{
key: 'scope',
title: '후속 작업 범위',
description: '선택 시안 기준으로 어떤 후속 작업을 이어갈지 고릅니다.',
multiple: true,
optional: true,
options: [
{
label: '모바일 여백 정리',
value: 'mobile-spacing',
description: '모바일 화면 여백과 버튼 배치를 먼저 다듬습니다.',
},
{
label: '상태 요약 추가',
value: 'summary-card',
description: '상단 요약 카드와 상태 문구를 함께 추가합니다.',
},
{
label: '미리보기 문서 생성',
value: 'preview-doc',
description: '세션 리소스에 HTML/Markdown 시안을 같이 생성합니다.',
},
],
freeTextLabel: '세부 요청',
freeTextPlaceholder: '예: 첫 단계는 시안만 정하고 구현은 다음 응답에서 이어가세요.',
},
],
responseTemplate: '사용자가 다음 단계형 흐름을 선택했습니다.\n{{step_summaries}}\n{{custom_text_block}}',
}}
onSubmit={async () => true}
/>
<ChatPromptCard
target={{
type: 'prompt',
title: '작업 결과안 자동 선택',
description: '응답 시간이 지나 시스템이 기본안을 선택했습니다.',
readOnly: true,
selectedValues: ['result-b'],
resolvedBy: 'timeout',
resultText: 'B안이 기본 시안으로 채택되었고, 다음 응답부터 이 흐름을 기준으로 이어갑니다.',
options: [
{
label: '결과안 A',
value: 'result-a',
description: '카드형 설명을 크게 보여주는 결과안',
preview: {
type: 'image',
url: 'https://images.unsplash.com/photo-1558655146-9f40138edfeb?auto=format&fit=crop&w=900&q=80',
alt: '결과안 A 샘플',
},
},
{
label: '결과안 B',
value: 'result-b',
description: '선택 요약과 다음 액션을 한 줄로 정리한 결과안',
preview: {
type: 'markdown',
content: '### 결과안 B\n선택 요약과 다음 액션을 한 줄로 정리합니다.',
},
},
{
label: '결과안 C',
value: 'result-c',
description: '추가 제안 링크를 함께 노출하는 결과안',
preview: {
type: 'resource',
url: '/docs/index.md',
title: '문서 리소스 예시',
},
},
],
}}
onSubmit={async () => false}
readOnly
/>
</Flex>
);
}

View File

@@ -21,6 +21,7 @@ import type {
EvidenceAttachmentPreviewBodyProps,
EvidenceAttachmentStripProps,
} from './types';
import { copyTextToClipboard } from '../../utils/clipboard';
import './EvidenceAttachmentStrip.css';
const { Paragraph, Text } = Typography;
@@ -108,25 +109,7 @@ function getAttachmentTypeIcon(kind: EvidenceAttachmentKind): ReactNode {
async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
const copyValue = attachment.copyValue ?? attachment.value;
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(copyValue);
return;
}
if (typeof document === 'undefined') {
throw new Error('clipboard-unavailable');
}
const textarea = document.createElement('textarea');
textarea.value = copyValue;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
return copyTextToClipboard(copyValue);
}
function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) {

View File

@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons';
import { Button, Empty, Segmented, Space, Tag, Typography, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { copyTextToClipboard } from '../../utils/clipboard';
import {
CODEX_DIFF_STATUS_LABEL_MAP,
CodexDiffBlock,
@@ -100,30 +101,9 @@ export function CodexDiffPreviewer({
const canShowDiff = Boolean(diffText);
const resolvedMode = mode === 'auto' ? activeMode : mode;
async function copyText(text: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
if (typeof document === 'undefined') {
throw new Error('클립보드 API를 사용할 수 없습니다.');
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
async function handleCopy(content: string) {
try {
await copyText(content);
await copyTextToClipboard(content);
messageApi.success('복사했습니다.');
} catch {
messageApi.error('복사에 실패했습니다.');

View File

@@ -169,17 +169,22 @@
padding: 1px 6px;
border-radius: 999px;
background: rgba(22, 93, 255, 0.08);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.previewer-ui__markdown pre code {
display: block;
padding: 0;
background: transparent;
white-space: pre;
}
.previewer-ui__markdown pre {
margin: 0;
padding: 14px;
max-width: 100%;
overflow: auto;
color: #dbe7ff;
background: linear-gradient(180deg, #0f172a 0%, #111f39 100%);
@@ -187,6 +192,10 @@
}
.previewer-ui__editor {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: hidden;
width: 100%;
border: 0;
@@ -235,6 +244,10 @@
}
.previewer-ui__editor-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
padding: 12px 0;
}

View File

@@ -9,6 +9,7 @@ import { Button, Empty, Input, Select, message } from 'antd';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { InlineImage } from '../common/InlineImage';
import { copyTextToClipboard } from '../../utils/clipboard';
import { CodexDiffBlock } from './CodexDiffBlock';
import type { PreviewerUIProps } from './types';
import { inferCodeLanguage, renderEditorBlock } from './renderers';
@@ -121,27 +122,6 @@ function renderMarkdown(markdown: string) {
return blocks;
}
async function copyText(text: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
if (typeof document === 'undefined') {
throw new Error('클립보드 API를 사용할 수 없습니다.');
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
function downloadBlob(content: BlobPart, fileName: string, mimeType = 'text/plain;charset=utf-8') {
if (typeof document === 'undefined') {
throw new Error('다운로드를 사용할 수 없습니다.');
@@ -340,7 +320,7 @@ export function PreviewerUI({
}
try {
await copyText(resolvedCopyValue);
await copyTextToClipboard(resolvedCopyValue);
messageApi.success('복사했습니다.');
} catch {
messageApi.error('복사에 실패했습니다.');