feat: update codex live automation and plan flows

This commit is contained in:
2026-04-24 08:06:36 +09:00
parent 916107dbe5
commit f2d6310efa
47 changed files with 2767 additions and 507 deletions

View File

@@ -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() ? (