feat: expand live chat and work server tools

This commit is contained in:
2026-04-30 11:40:02 +09:00
parent 42ae640470
commit 2df0ba30cb
112 changed files with 15241 additions and 996 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
import {
CheckOutlined,
DeleteOutlined,
LeftOutlined,
PlusOutlined,
RightOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Button, Empty, Input, Tag, Typography } from 'antd';
import type { SyntheticEvent } from 'react';
import { InputUI } from '../../components/inputs/primitives/input';
import { SelectUI, type SelectOptionItem } from '../../components/inputs/select';
import type {
LayoutPreviewBaseInputState,
LayoutPreviewEmptyPaneState,
LayoutPreviewMemoState,
LayoutPreviewSelectState,
} from './layoutPreviewRuntime';
const { Text } = Typography;
function stopPreviewEvent(event: SyntheticEvent) {
event.stopPropagation();
}
function formatMemoTimestamp(value: string) {
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
function getMemoPreview(body: string) {
return body.replace(/\s+/g, ' ').trim() || '새 메모';
}
function formatEmptyPaneTimestamp(value: string | null) {
if (!value) {
return '아직 메모가 없습니다.';
}
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
const EMPTY_PANE_READINESS_META: Record<
LayoutPreviewEmptyPaneState['readiness'],
{ label: string; tone: 'default' | 'processing' | 'success' }
> = {
unassigned: { label: '컴포넌트 대기', tone: 'default' },
drafting: { label: '요구 정리 중', tone: 'processing' },
ready: { label: '준비 완료', tone: 'success' },
};
export function LayoutPreviewTextMemoPane({
state,
onStartDraft,
onToggleList,
onDeleteSelection,
onSaveDraft,
onSelectNote,
onMoveSelection,
onDraftChange,
}: {
state: LayoutPreviewMemoState;
onStartDraft: () => void;
onToggleList: () => void;
onDeleteSelection: () => void;
onSaveDraft: () => void;
onSelectNote: (noteId: string) => void;
onMoveSelection: (direction: -1 | 1) => void;
onDraftChange: (nextValue: string) => void;
}) {
const selectedIndex = state.selectedId ? state.notes.findIndex((note) => note.id === state.selectedId) : -1;
const selectedNote = selectedIndex >= 0 ? state.notes[selectedIndex] : null;
const hasDraft = state.draftBody.trim().length > 0;
return (
<div className="layout-playground__memo-widget-preview" onClick={stopPreviewEvent}>
<div className="layout-playground__memo-widget-preview-toolbar" role="toolbar" aria-label="메모 도구">
<div className="layout-playground__memo-widget-preview-toolbar-group">
<Button type="text" shape="circle" htmlType="button" icon={<PlusOutlined />} aria-label="새 메모" onClick={onStartDraft} />
<Button
type="text"
shape="circle"
htmlType="button"
icon={<DeleteOutlined />}
aria-label="메모 삭제"
disabled={!selectedNote && !hasDraft}
onClick={onDeleteSelection}
/>
<Button
type="text"
shape="circle"
htmlType="button"
icon={<UnorderedListOutlined />}
aria-label="메모 목록"
onClick={onToggleList}
/>
</div>
<div className="layout-playground__memo-widget-preview-toolbar-group">
<Button
type="text"
shape="circle"
htmlType="button"
icon={<LeftOutlined />}
disabled={selectedIndex <= 0}
onClick={() => {
onMoveSelection(-1);
}}
/>
<Button
type="text"
shape="circle"
htmlType="button"
icon={<RightOutlined />}
disabled={selectedIndex < 0 || selectedIndex >= state.notes.length - 1}
onClick={() => {
onMoveSelection(1);
}}
/>
<Button
type="text"
shape="circle"
htmlType="button"
icon={<CheckOutlined />}
aria-label="저장"
disabled={!hasDraft && !selectedNote}
onClick={onSaveDraft}
/>
</div>
</div>
<div className={`layout-playground__memo-widget-preview-body${state.isListOpen ? ' layout-playground__memo-widget-preview-body--list' : ''}`}>
{state.isLoading ? (
<div className="layout-playground__memo-widget-preview-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="메모를 준비하는 중입니다." />
</div>
) : state.isListOpen ? (
<div className="layout-playground__memo-widget-preview-sheet">
{state.notes.length ? (
<div className="layout-playground__memo-widget-preview-list">
{state.notes.map((note) => (
<button
key={note.id}
type="button"
className={`layout-playground__memo-widget-preview-item${note.id === state.selectedId ? ' layout-playground__memo-widget-preview-item--active' : ''}`}
onClick={() => {
onSelectNote(note.id);
}}
>
<span className="layout-playground__memo-widget-preview-item-time">
{formatMemoTimestamp(note.updatedAt)}
</span>
<span className="layout-playground__memo-widget-preview-item-copy">{getMemoPreview(note.body)}</span>
</button>
))}
</div>
) : (
<div className="layout-playground__memo-widget-preview-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 메모가 없습니다." />
</div>
)}
</div>
) : (
<div className="layout-playground__memo-widget-preview-editor">
<div className="layout-playground__memo-widget-preview-meta">
<span>{selectedNote ? formatMemoTimestamp(selectedNote.updatedAt) : '새 메모'}</span>
<span>{state.draftBody.length}/1200</span>
</div>
<Input.TextArea
className="layout-playground__memo-widget-preview-input"
value={state.draftBody}
placeholder="본문 메모를 입력하세요."
variant="borderless"
maxLength={1200}
autoSize={false}
onChange={(event) => {
onDraftChange(event.target.value);
}}
/>
</div>
)}
</div>
</div>
);
}
export function LayoutPreviewBaseInputPane({
state,
fillPane = false,
placeholder = '입력 후 Enter 또는 blur',
}: {
state: LayoutPreviewBaseInputState;
fillPane?: boolean;
placeholder?: string;
}) {
return (
<div
className={`layout-playground__base-input-preview${fillPane ? ' layout-playground__base-input-preview--fill' : ''}`}
onClick={stopPreviewEvent}
>
<InputUI
className="layout-playground__base-input-preview-field"
value={state.value}
placeholder={placeholder}
readOnly
/>
</div>
);
}
export function LayoutPreviewSelectPane({
state,
data,
onChange,
}: {
state: LayoutPreviewSelectState;
data: SelectOptionItem[];
onChange: (nextCode?: string, item?: SelectOptionItem) => void;
}) {
const resolvedSelectedCode =
state.selectedCode && data.some((item) => item.code === state.selectedCode)
? state.selectedCode
: data[0]?.code;
const formatComboLabel = (item: SelectOptionItem) => item.value;
return (
<div className="layout-playground__select-preview" onClick={stopPreviewEvent}>
<SelectUI
data={data}
value={resolvedSelectedCode}
allowClear
placeholder="콤보 값을 선택하세요"
formatLabel={formatComboLabel}
onChange={onChange}
/>
</div>
);
}
export function LayoutPreviewEmptyPane({
paneLabel,
paneDescription,
sizeSummary,
state,
onReadinessChange,
onNoteChange,
}: {
paneLabel: string;
paneDescription: string;
sizeSummary: string;
state: LayoutPreviewEmptyPaneState;
onReadinessChange: (nextValue: LayoutPreviewEmptyPaneState['readiness']) => void;
onNoteChange: (nextValue: string) => void;
}) {
const readinessMeta = EMPTY_PANE_READINESS_META[state.readiness];
return (
<div className="layout-playground__empty-pane-preview" onClick={stopPreviewEvent}>
<div className="layout-playground__empty-pane-preview-head">
<div className="layout-playground__empty-pane-preview-copy">
<Text strong>{paneLabel}</Text>
<Text type="secondary">{paneDescription}</Text>
</div>
<Tag color={readinessMeta.tone}>{readinessMeta.label}</Tag>
</div>
<div className="layout-playground__empty-pane-preview-meta">
<div className="layout-playground__empty-pane-preview-meta-item">
<span> </span>
<strong>{sizeSummary}</strong>
</div>
<div className="layout-playground__empty-pane-preview-meta-item">
<span> </span>
<strong>{formatEmptyPaneTimestamp(state.updatedAt)}</strong>
</div>
</div>
<div className="layout-playground__empty-pane-preview-actions">
<Button type={state.readiness === 'unassigned' ? 'primary' : 'default'} size="small" onClick={() => onReadinessChange('unassigned')}>
</Button>
<Button type={state.readiness === 'drafting' ? 'primary' : 'default'} size="small" onClick={() => onReadinessChange('drafting')}>
</Button>
<Button type={state.readiness === 'ready' ? 'primary' : 'default'} size="small" onClick={() => onReadinessChange('ready')}>
</Button>
</div>
<Input.TextArea
className="layout-playground__empty-pane-preview-note"
value={state.note}
placeholder="이 section에 들어갈 역할이나 메모를 간단히 정리하세요."
autoSize={{ minRows: 4, maxRows: 8 }}
maxLength={400}
onChange={(event) => {
onNoteChange(event.target.value);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { Button, Tag, Typography } from 'antd';
import type { ReactNode } from 'react';
const { Paragraph, Text } = Typography;
export type LayoutSavedPaneSpec = {
summary: string;
};
export function LayoutSavedPanePlaceholder({
label,
selected,
spec,
action,
}: {
label: string;
selected: boolean;
spec: LayoutSavedPaneSpec | null;
action?: ReactNode;
}) {
return (
<div className={`layout-playground__saved-pane-placeholder${selected ? ' is-selected' : ''}`}>
<div className="layout-playground__saved-pane-placeholder-head">
<Tag color={selected ? 'blue' : 'default'}>{label}</Tag>
<Tag bordered={false} className="layout-playground__saved-pane-placeholder-status">
</Tag>
</div>
<div className="layout-playground__saved-pane-placeholder-body">
<Text strong className="layout-playground__saved-pane-placeholder-title">
.
</Text>
<Paragraph className="layout-playground__saved-pane-placeholder-copy">
section은 . .
</Paragraph>
{spec ? (
<div className="layout-playground__saved-pane-placeholder-meta">
<div className="layout-playground__saved-pane-placeholder-meta-item layout-playground__saved-pane-placeholder-meta-item--wide">
<span> </span>
<strong>{spec.summary}</strong>
</div>
</div>
) : null}
</div>
{action ? <div className="layout-playground__saved-pane-placeholder-actions">{action}</div> : null}
</div>
);
}
export function LayoutSavedSelectionSummary({
items,
selectedId,
onSelect,
spec,
}: {
items: Array<{ id: string; label: string }>;
selectedId: string | null;
onSelect: (id: string) => void;
spec: LayoutSavedPaneSpec | null;
}) {
return (
<div className="layout-playground__saved-selection-summary">
<div className="layout-playground__saved-selection-summary-copy">
<Text strong>{items.find((item) => item.id === selectedId)?.label ?? 'Pane 선택 없음'}</Text>
<Text type="secondary">
{spec ? spec.summary : '분할 정보가 없습니다.'}
</Text>
</div>
<div className="layout-playground__saved-selection-summary-actions">
{items.map((item) => (
<Button
key={item.id}
size="small"
type={item.id === selectedId ? 'primary' : 'default'}
onClick={() => {
onSelect(item.id);
}}
>
{item.label}
</Button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { LAYOUT_EDITOR_CHAT_TYPE_ID } from '../../app/main/chatTypeDefaults';
type LayoutCodexChatType = {
id: string;
name: string;
description: string;
};
const PRIORITIZED_CHAT_TYPE_IDS = [LAYOUT_EDITOR_CHAT_TYPE_ID, 'general-request', 'api-request-template'] as const;
export function resolvePreferredLayoutCodexChatType(chatTypes: LayoutCodexChatType[]) {
for (const id of PRIORITIZED_CHAT_TYPE_IDS) {
const matched = chatTypes.find((item) => item.id === id);
if (matched) {
return matched;
}
}
return chatTypes.find((item) => item.id !== 'general-inquiry') ?? chatTypes[0] ?? null;
}

View File

@@ -0,0 +1,484 @@
import { useEffect, useMemo, useState } from 'react';
import type { SelectOptionItem } from '../../components/inputs/select';
type LayoutComponentBinding = {
optionId: string;
label: string;
};
type LayoutLeafNode = {
id: string;
componentBinding: LayoutComponentBinding | null;
};
export type LayoutPreviewBindingKind = 'base-input' | 'select-input' | 'text-memo-widget' | 'sample';
export type LayoutPreviewMemoNote = {
id: string;
body: string;
updatedAt: string;
};
export type LayoutPreviewMemoState = {
draftBody: string;
selectedId: string | null;
isListOpen: boolean;
isLoading: boolean;
notes: LayoutPreviewMemoNote[];
};
export type LayoutPreviewBaseInputState = {
value: string;
};
export type LayoutPreviewSelectState = {
selectedCode: string | undefined;
selectedItem: SelectOptionItem | null;
};
export type LayoutPreviewEmptyPaneState = {
readiness: 'unassigned' | 'drafting' | 'ready';
note: string;
updatedAt: string | null;
};
export type LayoutPreviewInteractionRule = {
sourceLeafId: string;
targetLeafId: string;
};
type LayoutPreviewRuntime = {
memoStates: Record<string, LayoutPreviewMemoState>;
baseInputStates: Record<string, LayoutPreviewBaseInputState>;
selectStates: Record<string, LayoutPreviewSelectState>;
emptyPaneStates: Record<string, LayoutPreviewEmptyPaneState>;
setMemoDraftBody: (leafId: string, nextValue: string) => void;
toggleMemoList: (leafId: string) => void;
saveMemoDraft: (leafId: string) => void;
startMemoDraft: (leafId: string) => void;
selectMemoNote: (leafId: string, noteId: string) => void;
moveMemoSelection: (leafId: string, direction: -1 | 1) => void;
deleteMemoSelection: (leafId: string) => void;
setSelectValue: (leafId: string, nextCode?: string, nextItem?: SelectOptionItem) => void;
setEmptyPaneReadiness: (leafId: string, readiness: LayoutPreviewEmptyPaneState['readiness']) => void;
setEmptyPaneNote: (leafId: string, nextValue: string) => void;
};
const EMPTY_MEMO_STATE: LayoutPreviewMemoState = {
draftBody: '',
selectedId: null,
isListOpen: false,
isLoading: false,
notes: [],
};
const EMPTY_BASE_INPUT_STATE: LayoutPreviewBaseInputState = {
value: '',
};
const EMPTY_SELECT_STATE: LayoutPreviewSelectState = {
selectedCode: undefined,
selectedItem: null,
};
const EMPTY_EMPTY_PANE_STATE: LayoutPreviewEmptyPaneState = {
readiness: 'unassigned',
note: '',
updatedAt: null,
};
function createMemoNote(body: string): LayoutPreviewMemoNote {
const timestamp = new Date().toISOString();
return {
id: `memo-preview-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
body,
updatedAt: timestamp,
};
}
function extractMemoFirstLine(body: string) {
return body
.split(/\r?\n/u)
.map((line) => line.trim())
.find(Boolean) ?? '';
}
export function resolveLayoutPreviewBindingKind(binding: LayoutComponentBinding | null): LayoutPreviewBindingKind {
if (!binding) {
return 'sample';
}
if (
binding.optionId === 'component:input:deferred-input' ||
binding.optionId === 'component:input:input-base' ||
binding.label === 'Base Input'
) {
return 'base-input';
}
if (
binding.optionId === 'component:select-input:select-input-base' ||
binding.optionId === 'component:select-input:select-input' ||
binding.label === 'Select Input'
) {
return 'select-input';
}
if (binding.optionId === 'widget:text-memo-widget:text-memo-widget' || binding.label === 'Text Memo Widget') {
return 'text-memo-widget';
}
return 'sample';
}
function syncRecordKeys<T>(
previous: Record<string, T>,
nextKeys: string[],
createValue: () => T,
) {
const nextSet = new Set(nextKeys);
let changed = false;
const nextRecord: Record<string, T> = {};
nextKeys.forEach((key) => {
if (key in previous) {
nextRecord[key] = previous[key];
return;
}
nextRecord[key] = createValue();
changed = true;
});
Object.keys(previous).forEach((key) => {
if (!nextSet.has(key)) {
changed = true;
}
});
return changed ? nextRecord : previous;
}
export function useLayoutPreviewRuntime(
leafNodes: LayoutLeafNode[],
interactionRules: LayoutPreviewInteractionRule[],
): LayoutPreviewRuntime {
const leafBindingKindMap = useMemo(
() => new Map(leafNodes.map((leaf) => [leaf.id, resolveLayoutPreviewBindingKind(leaf.componentBinding)])),
[leafNodes],
);
const baseInputLeafIds = useMemo(
() =>
leafNodes
.filter((leaf) => resolveLayoutPreviewBindingKind(leaf.componentBinding) === 'base-input')
.map((leaf) => leaf.id),
[leafNodes],
);
const memoLeafIds = useMemo(
() =>
leafNodes
.filter((leaf) => resolveLayoutPreviewBindingKind(leaf.componentBinding) === 'text-memo-widget')
.map((leaf) => leaf.id),
[leafNodes],
);
const selectLeafIds = useMemo(
() =>
leafNodes
.filter((leaf) => resolveLayoutPreviewBindingKind(leaf.componentBinding) === 'select-input')
.map((leaf) => leaf.id),
[leafNodes],
);
const [memoStates, setMemoStates] = useState<Record<string, LayoutPreviewMemoState>>({});
const [baseInputStates, setBaseInputStates] = useState<Record<string, LayoutPreviewBaseInputState>>({});
const [selectStates, setSelectStates] = useState<Record<string, LayoutPreviewSelectState>>({});
const [emptyPaneStates, setEmptyPaneStates] = useState<Record<string, LayoutPreviewEmptyPaneState>>({});
const memoToBaseInputTargets = useMemo(() => {
const nextMap = new Map<string, string[]>();
interactionRules.forEach((rule) => {
const sourceKind = leafBindingKindMap.get(rule.sourceLeafId);
const targetKind = leafBindingKindMap.get(rule.targetLeafId);
if (sourceKind !== 'text-memo-widget' || targetKind !== 'base-input') {
return;
}
const nextTargets = nextMap.get(rule.sourceLeafId) ?? [];
if (!nextTargets.includes(rule.targetLeafId)) {
nextTargets.push(rule.targetLeafId);
}
nextMap.set(rule.sourceLeafId, nextTargets);
});
return nextMap;
}, [interactionRules, leafBindingKindMap]);
const syncBaseInputsFromMemo = (sourceLeafId: string, draftBody: string) => {
const targetLeafIds = memoToBaseInputTargets.get(sourceLeafId) ?? [];
if (!targetLeafIds.length) {
return;
}
const nextValue = extractMemoFirstLine(draftBody);
setBaseInputStates((previous) => {
const nextState = { ...previous };
targetLeafIds.forEach((leafId) => {
nextState[leafId] = {
...(previous[leafId] ?? EMPTY_BASE_INPUT_STATE),
value: nextValue,
};
});
return nextState;
});
};
useEffect(() => {
setBaseInputStates((previous) => syncRecordKeys(previous, baseInputLeafIds, () => ({ ...EMPTY_BASE_INPUT_STATE })));
}, [baseInputLeafIds]);
useEffect(() => {
setMemoStates((previous) => syncRecordKeys(previous, memoLeafIds, () => ({ ...EMPTY_MEMO_STATE })));
}, [memoLeafIds]);
useEffect(() => {
setSelectStates((previous) => syncRecordKeys(previous, selectLeafIds, () => ({ ...EMPTY_SELECT_STATE })));
}, [selectLeafIds]);
useEffect(() => {
const emptyPaneLeafIds = leafNodes.filter((leaf) => !leaf.componentBinding).map((leaf) => leaf.id);
setEmptyPaneStates((previous) => syncRecordKeys(previous, emptyPaneLeafIds, () => ({ ...EMPTY_EMPTY_PANE_STATE })));
}, [leafNodes]);
const setMemoDraftBody = (leafId: string, nextValue: string) => {
syncBaseInputsFromMemo(leafId, nextValue);
setMemoStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_MEMO_STATE),
draftBody: nextValue,
},
}));
};
const toggleMemoList = (leafId: string) => {
setMemoStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_MEMO_STATE),
isListOpen: !(previous[leafId]?.isListOpen ?? false),
},
}));
};
const saveMemoDraft = (leafId: string) => {
const current = memoStates[leafId] ?? EMPTY_MEMO_STATE;
const trimmedBody = current.draftBody.trim();
if (!trimmedBody) {
return;
}
syncBaseInputsFromMemo(leafId, trimmedBody);
setMemoStates((previous) => {
const nextCurrent = previous[leafId] ?? EMPTY_MEMO_STATE;
const matchedNote = nextCurrent.selectedId ? nextCurrent.notes.find((note) => note.id === nextCurrent.selectedId) ?? null : null;
const nextNote = matchedNote
? {
...matchedNote,
body: trimmedBody,
updatedAt: new Date().toISOString(),
}
: createMemoNote(trimmedBody);
const filteredNotes = nextCurrent.notes.filter((note) => note.id !== nextNote.id);
return {
...previous,
[leafId]: {
...nextCurrent,
draftBody: nextNote.body,
selectedId: nextNote.id,
isListOpen: false,
notes: [nextNote, ...filteredNotes].slice(0, 12),
},
};
});
};
const startMemoDraft = (leafId: string) => {
syncBaseInputsFromMemo(leafId, '');
setMemoStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_MEMO_STATE),
draftBody: '',
selectedId: null,
isListOpen: false,
},
}));
};
const selectMemoNote = (leafId: string, noteId: string) => {
const current = memoStates[leafId] ?? EMPTY_MEMO_STATE;
const selectedNote = current.notes.find((note) => note.id === noteId) ?? null;
if (!selectedNote) {
return;
}
syncBaseInputsFromMemo(leafId, selectedNote.body);
setMemoStates((previous) => {
const nextCurrent = previous[leafId] ?? EMPTY_MEMO_STATE;
const nextSelectedNote = nextCurrent.notes.find((note) => note.id === noteId) ?? null;
if (!nextSelectedNote) {
return previous;
}
return {
...previous,
[leafId]: {
...nextCurrent,
selectedId: nextSelectedNote.id,
draftBody: nextSelectedNote.body,
isListOpen: false,
},
};
});
};
const deleteMemoSelection = (leafId: string) => {
const current = memoStates[leafId] ?? EMPTY_MEMO_STATE;
if (!current.selectedId && !current.draftBody.trim()) {
return;
}
if (!current.selectedId) {
syncBaseInputsFromMemo(leafId, '');
} else {
const nextNotes = current.notes.filter((note) => note.id !== current.selectedId);
const nextSelectedNote = nextNotes[0] ?? null;
syncBaseInputsFromMemo(leafId, nextSelectedNote?.body ?? '');
}
setMemoStates((previous) => {
const nextCurrent = previous[leafId] ?? EMPTY_MEMO_STATE;
if (!nextCurrent.selectedId && !nextCurrent.draftBody.trim()) {
return previous;
}
if (!nextCurrent.selectedId) {
return {
...previous,
[leafId]: {
...nextCurrent,
draftBody: '',
},
};
}
const nextNotes = nextCurrent.notes.filter((note) => note.id !== nextCurrent.selectedId);
const nextSelectedNote = nextNotes[0] ?? null;
return {
...previous,
[leafId]: {
...nextCurrent,
notes: nextNotes,
selectedId: nextSelectedNote?.id ?? null,
draftBody: nextSelectedNote?.body ?? '',
},
};
});
};
const moveMemoSelection = (leafId: string, direction: -1 | 1) => {
setMemoStates((previous) => {
const current = previous[leafId] ?? EMPTY_MEMO_STATE;
if (!current.notes.length) {
return previous;
}
const baseIndex = current.selectedId
? current.notes.findIndex((note) => note.id === current.selectedId)
: 0;
const safeBaseIndex = baseIndex >= 0 ? baseIndex : 0;
const nextIndex = safeBaseIndex + direction;
if (nextIndex < 0 || nextIndex >= current.notes.length) {
return previous;
}
const nextSelectedNote = current.notes[nextIndex];
syncBaseInputsFromMemo(leafId, nextSelectedNote.body);
return {
...previous,
[leafId]: {
...current,
selectedId: nextSelectedNote.id,
draftBody: nextSelectedNote.body,
isListOpen: false,
},
};
});
};
const setSelectValue = (leafId: string, nextCode?: string, nextItem?: SelectOptionItem) => {
setSelectStates((previous) => ({
...previous,
[leafId]: {
selectedCode: nextCode,
selectedItem: nextItem ?? null,
},
}));
};
const setEmptyPaneReadiness = (leafId: string, readiness: LayoutPreviewEmptyPaneState['readiness']) => {
setEmptyPaneStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_EMPTY_PANE_STATE),
readiness,
updatedAt: new Date().toISOString(),
},
}));
};
const setEmptyPaneNote = (leafId: string, nextValue: string) => {
setEmptyPaneStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_EMPTY_PANE_STATE),
note: nextValue,
updatedAt: nextValue.trim() ? new Date().toISOString() : previous[leafId]?.updatedAt ?? null,
},
}));
};
return {
memoStates,
baseInputStates,
selectStates,
emptyPaneStates,
setMemoDraftBody,
toggleMemoList,
saveMemoDraft,
startMemoDraft,
selectMemoNote,
moveMemoSelection,
deleteMemoSelection,
setSelectValue,
setEmptyPaneReadiness,
setEmptyPaneNote,
};
}

View File

@@ -43,6 +43,22 @@ const PLAY_LAYOUTS_TABLE = 'play_layouts';
let setupPromise: Promise<void> | null = null;
function normalizeLayoutTimestamp(value: unknown, fallback: string) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) {
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
}
}
return fallback;
}
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
@@ -172,11 +188,13 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
function toRecord(row: SavedLayoutRow): SavedLayoutRecord {
const now = new Date().toISOString();
return {
id: row.id,
name: row.name,
createdAt: row.created_at,
updatedAt: row.updated_at,
createdAt: normalizeLayoutTimestamp(row.created_at, now),
updatedAt: normalizeLayoutTimestamp(row.updated_at, now),
axis: row.axis,
sizeUnit: row.size_unit,
primarySize: Number(row.primary_size),
@@ -191,11 +209,15 @@ function toRecord(row: SavedLayoutRow): SavedLayoutRecord {
}
function toRow(record: SavedLayoutRecord): SavedLayoutRow {
const now = new Date().toISOString();
const createdAt = normalizeLayoutTimestamp(record.createdAt, now);
const updatedAt = normalizeLayoutTimestamp(record.updatedAt, createdAt);
return {
id: record.id,
name: record.name,
created_at: record.createdAt,
updated_at: record.updatedAt,
created_at: createdAt,
updated_at: updatedAt,
axis: record.axis,
size_unit: record.sizeUnit,
primary_size: record.primarySize,
@@ -286,7 +308,20 @@ export async function saveLayout(record: SavedLayoutRecord) {
await request<{ ok: boolean }>(`/crud/${PLAY_LAYOUTS_TABLE}/update`, {
method: 'PATCH',
body: JSON.stringify({
data: row,
data: {
name: row.name,
updated_at: row.updated_at,
axis: row.axis,
size_unit: row.size_unit,
primary_size: row.primary_size,
primary_min: row.primary_min,
secondary_min: row.secondary_min,
resizable: row.resizable,
selected_leaf_id: row.selected_leaf_id,
total_panes: row.total_panes,
summary: row.summary,
tree: row.tree,
},
where: [{ field: 'id', operator: 'eq', value: record.id }],
}),
});