feat: update codex live automation and plan flows
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
DownOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
PaperClipOutlined,
|
||||
UpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
@@ -28,8 +29,16 @@ import {
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
|
||||
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
|
||||
import {
|
||||
buildAutomationTypeOptions,
|
||||
resolveAutomationTypeLabel,
|
||||
useAutomationTypeRegistry,
|
||||
} from '../../app/main/automationTypeAccess';
|
||||
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
|
||||
import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl';
|
||||
import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import './planBoard.css';
|
||||
import {
|
||||
@@ -67,7 +76,6 @@ import { maskNotePreviewByWord } from './noteMasking';
|
||||
import type {
|
||||
PlanActionHistory,
|
||||
PlanActionType,
|
||||
PlanAutomationType,
|
||||
PlanAutomationUsageSnapshot,
|
||||
PlanDraft,
|
||||
PlanFilterStatus,
|
||||
@@ -130,16 +138,6 @@ const MAIN_STATE_FILTER_OPTIONS: Array<{ label: string; value: MainStateFilter }
|
||||
{ label: 'main 실패', value: 'failed' },
|
||||
{ label: 'main 미대상', value: 'not-targeted' },
|
||||
];
|
||||
const PLAN_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: PlanAutomationType }> = [
|
||||
{ label: '선택 안함', value: 'none' },
|
||||
{ label: '작업 요청 등록', value: 'plan' },
|
||||
{ label: 'Command 실행', value: 'command_execution' },
|
||||
{ label: '비 소스작업', value: 'non_source_work' },
|
||||
{ label: 'autoWorker', value: 'auto_worker' },
|
||||
];
|
||||
const PLAN_AUTOMATION_TYPE_LABELS = new Map(
|
||||
PLAN_AUTOMATION_TYPE_OPTIONS.map((option) => [option.value, option.label] as const),
|
||||
);
|
||||
const ISSUE_STATE_FILTER_OPTIONS: Array<{ label: string; value: IssueStateFilter }> = [
|
||||
{ label: '이슈 전체', value: 'all' },
|
||||
{ label: '열린 이슈', value: 'open' },
|
||||
@@ -160,6 +158,191 @@ type ReviewListIndicator = {
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
type PlanNoteResource = {
|
||||
id: string;
|
||||
label: string;
|
||||
sourcePath: string;
|
||||
publicUrl: string;
|
||||
previewType: 'image' | 'document' | 'link';
|
||||
};
|
||||
|
||||
const PLAN_NOTE_RESOURCE_LINE_PATTERN =
|
||||
/^\s*-\s+(.+?):\s+((?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+))\s*$/;
|
||||
const PLAN_NOTE_RESOURCE_GLOBAL_PATTERN =
|
||||
/(?:\/api\/chat\/resources\/[^\s)`]+)|(?:\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)|(?:public\/\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+)/g;
|
||||
const PLAN_NOTE_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp']);
|
||||
const PLAN_NOTE_DOCUMENT_EXTENSIONS = new Set([
|
||||
'pdf',
|
||||
'txt',
|
||||
'md',
|
||||
'json',
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'css',
|
||||
'html',
|
||||
'diff',
|
||||
'log',
|
||||
]);
|
||||
|
||||
function createPlanNoteAttachmentSessionId() {
|
||||
return `plan-note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function normalizePlanNoteResourceSourcePath(value: string) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/[)>.,]+$/, '')
|
||||
.replace(/^\/+/, '/')
|
||||
.replace(/^public\/(?=\.codex_chat\/)/, '');
|
||||
}
|
||||
|
||||
function normalizePlanNoteResourceUrl(value: string) {
|
||||
const normalizedSourcePath = normalizePlanNoteResourceSourcePath(value);
|
||||
if (!normalizedSourcePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('/api/chat/resources/')) {
|
||||
return normalizeChatResourceUrl(normalizedSourcePath);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('/.codex_chat/')) {
|
||||
return normalizeChatResourceUrl(normalizedSourcePath);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('.codex_chat/')) {
|
||||
return normalizeChatResourceUrl(`/${normalizedSourcePath}`);
|
||||
}
|
||||
|
||||
return normalizeChatResourceUrl(normalizedSourcePath);
|
||||
}
|
||||
|
||||
function getPlanNoteResourceBaseName(sourcePath: string) {
|
||||
const normalized = normalizePlanNoteResourceSourcePath(sourcePath).replace(/^\/+/, '');
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
return segments.at(-1) ?? normalized;
|
||||
}
|
||||
|
||||
function resolvePlanNoteResourcePreviewType(sourcePath: string): PlanNoteResource['previewType'] {
|
||||
const baseName = getPlanNoteResourceBaseName(sourcePath);
|
||||
const extension = baseName.includes('.') ? baseName.split('.').at(-1)?.toLowerCase() ?? '' : '';
|
||||
|
||||
if (PLAN_NOTE_IMAGE_EXTENSIONS.has(extension)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (PLAN_NOTE_DOCUMENT_EXTENSIONS.has(extension)) {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
return 'link';
|
||||
}
|
||||
|
||||
function extractPlanNoteResources(note: string) {
|
||||
const normalizedNote = String(note ?? '');
|
||||
const lineEntries = normalizedNote
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const matched = line.match(PLAN_NOTE_RESOURCE_LINE_PATTERN);
|
||||
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: matched[1]?.trim() || getPlanNoteResourceBaseName(matched[2] ?? ''),
|
||||
sourcePath: normalizePlanNoteResourceSourcePath(matched[2] ?? ''),
|
||||
};
|
||||
})
|
||||
.filter((item): item is { label: string; sourcePath: string } => Boolean(item?.sourcePath));
|
||||
|
||||
const seen = new Set(lineEntries.map((item) => item.sourcePath));
|
||||
const genericEntries = Array.from(normalizedNote.matchAll(PLAN_NOTE_RESOURCE_GLOBAL_PATTERN))
|
||||
.map((matched) => normalizePlanNoteResourceSourcePath(matched[0] ?? ''))
|
||||
.filter(Boolean)
|
||||
.filter((sourcePath) => {
|
||||
if (seen.has(sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(sourcePath);
|
||||
return true;
|
||||
})
|
||||
.map((sourcePath) => ({
|
||||
label: getPlanNoteResourceBaseName(sourcePath),
|
||||
sourcePath,
|
||||
}));
|
||||
|
||||
return [...lineEntries, ...genericEntries].map((item, index) => ({
|
||||
id: `${index}-${item.sourcePath}`,
|
||||
label: item.label,
|
||||
sourcePath: item.sourcePath,
|
||||
publicUrl: normalizePlanNoteResourceUrl(item.sourcePath),
|
||||
previewType: resolvePlanNoteResourcePreviewType(item.sourcePath),
|
||||
}));
|
||||
}
|
||||
|
||||
function appendPlanNoteAttachments(note: string, attachments: ChatComposerAttachment[]) {
|
||||
if (attachments.length === 0) {
|
||||
return note;
|
||||
}
|
||||
|
||||
const existingSourcePaths = new Set(extractPlanNoteResources(note).map((item) => item.sourcePath));
|
||||
const nextLines = attachments
|
||||
.map((attachment) => {
|
||||
const sourcePath = normalizePlanNoteResourceSourcePath(attachment.path);
|
||||
return {
|
||||
label: attachment.name.trim() || getPlanNoteResourceBaseName(sourcePath),
|
||||
sourcePath,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.sourcePath)
|
||||
.filter((item) => {
|
||||
if (existingSourcePaths.has(item.sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
existingSourcePaths.add(item.sourcePath);
|
||||
return true;
|
||||
})
|
||||
.map((item) => `- ${item.label}: ${item.sourcePath}`);
|
||||
|
||||
if (nextLines.length === 0) {
|
||||
return note;
|
||||
}
|
||||
|
||||
const currentNote = note.trimEnd();
|
||||
const attachmentSectionPattern = /(^|\n)첨부 파일:\n(?:- .+\n?)*/;
|
||||
const matchedSection = currentNote.match(attachmentSectionPattern);
|
||||
|
||||
if (!matchedSection || matchedSection.index === undefined) {
|
||||
return `${currentNote}${currentNote ? '\n\n' : ''}첨부 파일:\n${nextLines.join('\n')}`;
|
||||
}
|
||||
|
||||
const startIndex = matchedSection.index;
|
||||
const matchedText = matchedSection[0];
|
||||
const insertIndex = startIndex + matchedText.length;
|
||||
const prefix = currentNote.slice(0, insertIndex).replace(/\n*$/, '\n');
|
||||
const suffix = currentNote.slice(insertIndex).replace(/^\n+/, '\n');
|
||||
|
||||
return `${prefix}${nextLines.join('\n')}${suffix}`;
|
||||
}
|
||||
|
||||
function resolvePlanNoteAttachmentSessionId(
|
||||
draftId: number | null,
|
||||
fallbackSessionId: string,
|
||||
) {
|
||||
if (draftId) {
|
||||
return `plan-note-${draftId}`;
|
||||
}
|
||||
|
||||
return fallbackSessionId;
|
||||
}
|
||||
|
||||
function isPlanItemRequestLocked(item: Pick<PlanItem, 'startedAt'> | null | undefined) {
|
||||
return Boolean(item?.startedAt);
|
||||
}
|
||||
@@ -317,6 +500,59 @@ function ExpandableDetailText({
|
||||
);
|
||||
}
|
||||
|
||||
function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] }) {
|
||||
return (
|
||||
<div className="plan-board-page__note-resources">
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>첨부 리소스</Text>
|
||||
<Text type="secondary">{resources.length}건</Text>
|
||||
</Flex>
|
||||
<div className="plan-board-page__note-resource-list">
|
||||
{resources.map((resource) => (
|
||||
<div key={resource.id} className="plan-board-page__note-resource-card">
|
||||
<Flex justify="space-between" align="start" gap={12} wrap>
|
||||
<Space direction="vertical" size={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text strong ellipsis={{ tooltip: resource.label }}>
|
||||
{resource.label}
|
||||
</Text>
|
||||
<Text type="secondary" className="plan-board-page__note-resource-path">
|
||||
{resource.sourcePath}
|
||||
</Text>
|
||||
</Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
href={resource.publicUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
열기
|
||||
</Button>
|
||||
</Flex>
|
||||
{resource.previewType === 'image' ? (
|
||||
<img
|
||||
className="plan-board-page__note-resource-image"
|
||||
src={resource.publicUrl}
|
||||
alt={resource.label}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
{resource.previewType === 'document' ? (
|
||||
<iframe
|
||||
className="plan-board-page__note-resource-frame"
|
||||
src={resource.publicUrl}
|
||||
title={resource.label}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ActionButton = {
|
||||
key: PlanActionType;
|
||||
label: string;
|
||||
@@ -352,6 +588,7 @@ export function PlanBoardPage({
|
||||
initialSelectedWorkId = null,
|
||||
}: PlanBoardPageProps) {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const appConfig = useAppConfig();
|
||||
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
@@ -365,6 +602,7 @@ export function PlanBoardPage({
|
||||
const [selectedSourceWork, setSelectedSourceWork] = useState<PlanSourceWorkHistory | null>(null);
|
||||
const [draft, setDraft] = useState<PlanDraft>(() => createEmptyDraft(appConfig));
|
||||
const [noteInputValue, setNoteInputValue] = useState('');
|
||||
const [noteAttachmentUploading, setNoteAttachmentUploading] = useState(false);
|
||||
const [actionNote, setActionNote] = useState('');
|
||||
const [issueActionNote, setIssueActionNote] = useState('');
|
||||
const [resolveLatestIssue, setResolveLatestIssue] = useState(false);
|
||||
@@ -393,6 +631,8 @@ export function PlanBoardPage({
|
||||
workId: initialSelectedWorkId,
|
||||
});
|
||||
const draftRef = useRef(draft);
|
||||
const noteAttachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const noteAttachmentSessionIdRef = useRef(createPlanNoteAttachmentSessionId());
|
||||
const savingRef = useRef(saving);
|
||||
const previousWorkerStatusMapRef = useRef<Map<number, string | null>>(new Map());
|
||||
const notifiedAutomationStartKeysRef = useRef<Set<string>>(new Set());
|
||||
@@ -404,6 +644,10 @@ export function PlanBoardPage({
|
||||
);
|
||||
const isAutoRefreshRunning = hasPendingAutomation && autoRefreshEnabled;
|
||||
const autoRefreshCountdownSeconds = Math.max(1, Math.ceil(autoRefreshRemainingMs / 1000));
|
||||
const noteResources = useMemo(
|
||||
() => (hasAccess ? extractPlanNoteResources(noteInputValue) : []),
|
||||
[hasAccess, noteInputValue],
|
||||
);
|
||||
|
||||
draftRef.current = draft;
|
||||
savingRef.current = saving;
|
||||
@@ -898,6 +1142,7 @@ export function PlanBoardPage({
|
||||
return;
|
||||
}
|
||||
|
||||
noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId();
|
||||
setDraft(createEmptyDraft(appConfig));
|
||||
setResolveLatestIssue(false);
|
||||
setRetryLatestIssue(true);
|
||||
@@ -907,6 +1152,7 @@ export function PlanBoardPage({
|
||||
}
|
||||
|
||||
function handleSelectItem(item: PlanItem) {
|
||||
noteAttachmentSessionIdRef.current = resolvePlanNoteAttachmentSessionId(item.id, noteAttachmentSessionIdRef.current);
|
||||
setDraft(toDraft(item));
|
||||
setResolveLatestIssue(false);
|
||||
setRetryLatestIssue(true);
|
||||
@@ -1042,6 +1288,58 @@ export function PlanBoardPage({
|
||||
};
|
||||
}
|
||||
|
||||
async function handleNoteAttachmentFilesPicked(files: File[]) {
|
||||
if (files.length === 0 || noteAttachmentUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
messageApi.error(TOKEN_ACCESS_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlanItemRequestLocked(selectedItem)) {
|
||||
messageApi.warning('자동화 접수된 항목은 첨부 파일을 추가할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setNoteAttachmentUploading(true);
|
||||
|
||||
try {
|
||||
const sessionId = resolvePlanNoteAttachmentSessionId(draftRef.current.id, noteAttachmentSessionIdRef.current);
|
||||
const uploadResults = await Promise.allSettled(files.map((file) => uploadChatComposerFile(sessionId, file)));
|
||||
const uploadedItems: ChatComposerAttachment[] = [];
|
||||
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) {
|
||||
const nextNote = appendPlanNoteAttachments(noteInputValue, uploadedItems);
|
||||
handleNoteChange(nextNote);
|
||||
messageApi.success(`첨부 파일 ${uploadedItems.length}건을 메모에 추가했습니다.`);
|
||||
}
|
||||
|
||||
if (failedFileNames.length > 0) {
|
||||
messageApi.error(`업로드 실패: ${failedFileNames.join(', ')}`);
|
||||
}
|
||||
} finally {
|
||||
setNoteAttachmentUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNoteAttachmentInputChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = '';
|
||||
void handleNoteAttachmentFilesPicked(files);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (savingRef.current || !draftRef.current.id) {
|
||||
return;
|
||||
@@ -1253,7 +1551,14 @@ export function PlanBoardPage({
|
||||
hasAccess && selectedItem && !isRequestLocked && isFunctionCheckEditableStatus(selectedItem.status),
|
||||
);
|
||||
const canSave = hasAccess && !isRequestLocked;
|
||||
const automationTypeLabel = PLAN_AUTOMATION_TYPE_LABELS.get(draft.automationType) ?? draft.automationType;
|
||||
const automationTypeOptions = useMemo(
|
||||
() => buildAutomationTypeOptions(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
);
|
||||
const automationTypeLabel = useMemo(
|
||||
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
|
||||
[automationTypes, draft.automationType],
|
||||
);
|
||||
const latestActionHistory = actionHistories[0] ?? null;
|
||||
const latestIssueHistory = issueHistories[0] ?? null;
|
||||
const releaseCompletedTimestamps = useMemo(
|
||||
@@ -1740,7 +2045,7 @@ export function PlanBoardPage({
|
||||
<Select
|
||||
className="plan-board-page__select plan-board-page__select--automation"
|
||||
value={draft.automationType}
|
||||
options={PLAN_AUTOMATION_TYPE_OPTIONS}
|
||||
options={automationTypeOptions}
|
||||
popupClassName="plan-board-page__select-popup"
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
||||
disabled={!hasAccess}
|
||||
@@ -1757,14 +2062,27 @@ export function PlanBoardPage({
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" gap={8} wrap>
|
||||
<Text strong>메모</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => void handleCopyText(noteInputValue)}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PaperClipOutlined />}
|
||||
disabled={!hasAccess || isRequestLocked}
|
||||
loading={noteAttachmentUploading}
|
||||
onClick={() => {
|
||||
noteAttachmentInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
첨부
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!hasAccess}
|
||||
onClick={() => void handleCopyText(noteInputValue)}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<div className="plan-board-page__notepad-frame">
|
||||
<TextArea
|
||||
@@ -1778,11 +2096,19 @@ export function PlanBoardPage({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={noteAttachmentInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="plan-board-page__hidden-file-input"
|
||||
onChange={handleNoteAttachmentInputChange}
|
||||
/>
|
||||
{isRequestLocked ? (
|
||||
<Text type="secondary">
|
||||
{hasAccess ? '자동화 접수된 항목은 본문을 수정할 수 없습니다.' : '조회 화면에서는 작업 메모를 40% 마스킹해 표시합니다.'}
|
||||
</Text>
|
||||
) : null}
|
||||
{noteResources.length ? <PlanNoteResourcePanel resources={noteResources} /> : null}
|
||||
</div>
|
||||
|
||||
{selectedReleaseReviewNote.trim() ? (
|
||||
|
||||
Reference in New Issue
Block a user