feat: update codex live automation and plan flows
This commit is contained in:
@@ -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' : ''}`}>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user