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

@@ -13,6 +13,11 @@ import {
} 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 {
buildAutomationTypeOptions,
resolveAutomationTypeLabel,
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
createBoardPost,
@@ -22,7 +27,7 @@ import {
setupBoard,
updateBoardPost,
} from './api';
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
import type { BoardDraft, BoardPost } from './types';
const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input;
@@ -34,18 +39,6 @@ const EMPTY_DRAFT: BoardDraft = {
automationType: 'none',
};
const BOARD_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: BoardAutomationType }> = [
{ label: '선택 안함', value: 'none' },
{ label: 'Plan', value: 'plan' },
{ label: 'Command 실행', value: 'command_execution' },
{ label: '비 소스작업', value: 'non_source_work' },
{ label: 'autoWorker', value: 'auto_worker' },
];
const BOARD_AUTOMATION_TYPE_LABELS = new Map(
BOARD_AUTOMATION_TYPE_OPTIONS.map((option) => [option.value, option.label] as const),
);
function formatDateTime(value: string) {
return new Date(value).toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
@@ -128,6 +121,7 @@ function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: numbe
export function BoardPage() {
const [messageApi, contextHolder] = message.useMessage();
const { automationTypes } = useAutomationTypeRegistry();
const [items, setItems] = useState<BoardPost[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
@@ -216,7 +210,14 @@ export function BoardPage() {
);
const dirtyDraftId = draftDirty && draft.id ? draft.id : null;
const automationStatus = resolveBoardAutomationStatus(draft.id, automationReceived, draftDirty, automationReceiveError);
const automationTypeLabel = BOARD_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 receivableIds = useMemo(
() =>
items
@@ -671,17 +672,17 @@ export function BoardPage() {
<Select
className="board-page__automation-select"
value={draft.automationType}
options={BOARD_AUTOMATION_TYPE_OPTIONS}
popupClassName="board-page__automation-select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
options={automationTypeOptions}
popupClassName="board-page__automation-select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={isDraftLocked}
onChange={(automationType) => {
setDraft((previous) => ({
...previous,
automationType,
}));
}}
/>
automationType,
}));
}}
/>
)}
</div>
<div className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}>

View File

@@ -1,4 +1,5 @@
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';
@@ -13,16 +14,7 @@ class BoardApiError extends Error {
}
function normalizeBoardAutomationType(value: unknown): BoardAutomationType {
return value === 'plan' ||
value === 'command_execution' ||
value === 'non_source_work' ||
value === 'auto_worker'
? value
: value === 'plan_registration'
? 'plan'
: value === 'general_development'
? 'auto_worker'
: 'none';
return normalizeAutomationTypeId(value);
}
function resolveBoardApiBaseUrl() {

View File

@@ -1,12 +1,4 @@
export const BOARD_AUTOMATION_TYPES = [
'none',
'plan',
'command_execution',
'non_source_work',
'auto_worker',
] as const;
export type BoardAutomationType = (typeof BOARD_AUTOMATION_TYPES)[number];
export type BoardAutomationType = string;
export type BoardPost = {
id: number;

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

View File

@@ -18,12 +18,15 @@ import {
message,
} from 'antd';
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import {
buildAutomationTypeOptions,
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import { useTokenAccess } from '../../app/main/tokenAccess';
import './planBoard.css';
import './planSchedule.css';
import { maskNotePreviewByWord } from './noteMasking';
import { PlanListDetailLayout } from './PlanListDetailLayout';
import type { PlanAutomationType } from './types';
import {
createPlanScheduledTask,
deletePlanScheduledTask,
@@ -77,14 +80,6 @@ const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => ({
const DEFAULT_DAILY_RUN_TIME = '09:00';
const KST_TIME_ZONE = 'Asia/Seoul';
const DAY_MS = 24 * 60 * 60 * 1000;
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' },
];
function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) {
const normalizedValue = Math.max(1, Math.round(Number(value) || 1));
@@ -327,6 +322,7 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
export function PlanSchedulePage() {
const { hasAccess } = useTokenAccess();
const { automationTypes } = useAutomationTypeRegistry();
const [messageApi, contextHolder] = message.useMessage();
const [items, setItems] = useState<PlanScheduledTask[]>([]);
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
@@ -536,6 +532,7 @@ export function PlanSchedulePage() {
emptyDetailTitle="스케줄 상세"
detailContent={
<PlanScheduleDetail
automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)}
draft={draft}
hasAccess={hasAccess}
selectedItem={selectedItem}
@@ -605,6 +602,7 @@ const PlanScheduleList = memo(function PlanScheduleList({
});
function PlanScheduleDetail({
automationTypeOptions,
draft,
hasAccess,
selectedItem,
@@ -612,6 +610,7 @@ function PlanScheduleDetail({
onChangeDraft,
onCopyText,
}: {
automationTypeOptions: Array<{ label: string; value: string }>;
draft: PlanScheduledTaskDraft;
hasAccess: boolean;
selectedItem: PlanScheduledTask | null;
@@ -701,7 +700,7 @@ function PlanScheduleDetail({
<Select
className="plan-schedule-page__select plan-schedule-page__select--automation"
value={draft.automationType}
options={PLAN_AUTOMATION_TYPE_OPTIONS}
options={automationTypeOptions}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}

View File

@@ -1,4 +1,5 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
import type {
PlanActionType,
@@ -25,16 +26,7 @@ function resolvePlanApiBaseUrl() {
}
function normalizePlanAutomationType(value: unknown): PlanAutomationType {
return value === 'plan' ||
value === 'command_execution' ||
value === 'non_source_work' ||
value === 'auto_worker'
? value
: value === 'plan_registration'
? 'plan'
: value === 'general_development'
? 'auto_worker'
: 'none';
return normalizeAutomationTypeId(value);
}
function resolveWorkServerFallbackBaseUrl() {
@@ -389,6 +381,7 @@ function normalizePlanItem(item: PlanItem): PlanItem {
return {
...item,
automationType: normalizePlanAutomationType(item.automationType),
automationBehaviorType: normalizeAutomationTypeId(item.automationBehaviorType),
releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '',
usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot),
};

View File

@@ -351,6 +351,53 @@
width: 100%;
}
.plan-board-page__hidden-file-input {
display: none;
}
.plan-board-page__note-resources {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.plan-board-page__note-resource-list {
display: grid;
gap: 12px;
}
.plan-board-page__note-resource-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 249, 255, 0.96));
}
.plan-board-page__note-resource-path {
word-break: break-all;
}
.plan-board-page__note-resource-image,
.plan-board-page__note-resource-frame {
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 12px;
background: #fff;
}
.plan-board-page__note-resource-image {
max-height: 320px;
object-fit: contain;
}
.plan-board-page__note-resource-frame {
min-height: 320px;
}
.plan-board-page__notepad-expand-button.ant-btn {
color: rgba(71, 98, 130, 0.92);
background: rgba(255, 255, 255, 0.9);

View File

@@ -1,10 +1,9 @@
export const PLAN_STATUSES = ['등록', '작업중', '작업완료', '릴리즈완료', '완료'] as const;
export const PLAN_FILTER_STATUSES = ['all', 'in-progress', 'done', 'error'] as const;
export const PLAN_AUTOMATION_TYPES = ['none', 'plan', 'command_execution', 'non_source_work', 'auto_worker'] as const;
export type PlanStatus = (typeof PLAN_STATUSES)[number];
export type PlanFilterStatus = (typeof PLAN_FILTER_STATUSES)[number];
export type PlanAutomationType = (typeof PLAN_AUTOMATION_TYPES)[number];
export type PlanAutomationType = string;
export type PlanActionType =
| 'start-work'
| 'complete-development'
@@ -111,6 +110,7 @@ export type PlanItem = {
workId: string;
note: string;
automationType: PlanAutomationType;
automationBehaviorType?: string;
releaseReviewNote: string;
noteMasked?: boolean;
status: PlanStatus;