From 6369cf76a797a2ef4ab8eda130131a0445862dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erman=20HAVU=C3=87?= Date: Sun, 17 May 2026 23:26:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20context=20panel=20enhancements=20?= =?UTF-8?q?=E2=80=94=20resizable=20panels,=20drag-and-drop=20todo=20orderi?= =?UTF-8?q?ng,=20and=20persistent=20sizes=20(#1269)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove max-h-80 cap on quick notes textarea so resized height is respected * feat: add drag & drop reordering to project todo items * feat: make todo panel resizable with density-aware sizing * feat: open plan import file picker at project root * feat: persist quick notes and todo panel sizes across sessions * refactor(ui): scale content height with padding in projectnotestodopanel * Update packages/ui/src/components/session/ProjectNotesTodoPanel.tsx Signed-off-by: Erman HAVUÇ * fix(ui): harden context panel resizing and import --------- Signed-off-by: Erman HAVUÇ Co-authored-by: Bohdan Triapitsyn --- packages/electron/main.mjs | 3 + .../session/ProjectNotesTodoPanel.tsx | 395 ++++++++++++++---- packages/ui/src/components/ui/textarea.tsx | 16 +- packages/ui/src/lib/desktop.ts | 3 +- packages/ui/src/lib/i18n/messages/en.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 + packages/ui/src/stores/useUIStore.ts | 28 +- 12 files changed, 377 insertions(+), 82 deletions(-) diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 0d8c4747..abfd9e52 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2575,6 +2575,9 @@ ipcMain.handle('openchamber:dialog:open', async (event, options) => { const browserWindow = BrowserWindow.fromWebContents(event.sender); const result = await dialog.showOpenDialog(browserWindow || undefined, { title: typeof options?.title === 'string' ? options.title : undefined, + defaultPath: typeof options?.defaultPath === 'string' && options.defaultPath.trim().length > 0 + ? options.defaultPath.trim() + : undefined, filters: Array.isArray(options?.filters) ? options.filters .filter((filter) => filter && typeof filter === 'object') diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx index 7d7f3837..f8c31127 100644 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx @@ -1,4 +1,14 @@ import React from 'react'; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; +import { CSS as DndCSS } from '@dnd-kit/utilities'; import { toast } from '@/components/ui'; import { Checkbox } from '@/components/ui/checkbox'; import { @@ -22,6 +32,7 @@ import { type OpenChamberProjectTodoItem, type ProjectRef, } from '@/lib/openchamberConfig'; +import { requestFileAccess } from '@/lib/desktop'; import { generateBranchName } from '@/lib/git/branchNameGenerator'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -35,6 +46,25 @@ import { renderMagicPrompt } from '@/lib/magicPrompts'; import { useI18n } from '@/lib/i18n'; import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog'; +const TODO_PANEL_MIN_ITEMS = 5; +const TODO_PANEL_MAX_ITEMS = 15; + +const getEffectiveItemHeight = (padding: number) => { + const scale = Math.sqrt(padding / 100); + const paddingPx = 12 * scale; + const contentPx = 24 * scale; // h-6 uses --spacing-6 which also scales with --padding-scale + const borderPx = 1; + return Math.ceil(paddingPx + contentPx + borderPx); +}; + +const getPanelHeightForItems = (itemCount: number, padding: number) => { + const itemHeight = getEffectiveItemHeight(padding); + return Math.max( + itemHeight * TODO_PANEL_MIN_ITEMS, + Math.min(itemHeight * TODO_PANEL_MAX_ITEMS, itemHeight * itemCount) + ); +}; + interface ProjectNotesTodoPanelProps { projectRef: ProjectRef | null; projectLabel?: string | null; @@ -71,6 +101,41 @@ const createTodoId = (): string => { return `todo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }; +type SortableTodoHandleProps = { + attributes: ReturnType['attributes']; + listeners: ReturnType['listeners']; + setActivatorNodeRef: ReturnType['setActivatorNodeRef']; + isDragging: boolean; +}; + +const SortableTodoItem: React.FC<{ + id: string; + children: (dragHandleProps: SortableTodoHandleProps) => React.ReactNode; +}> = ({ id, children }) => { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + return ( +
  • + {children({ attributes, listeners, setActivatorNodeRef, isDragging })} +
  • + ); +}; + export const ProjectNotesTodoPanel: React.FC = ({ projectRef, projectLabel, @@ -91,6 +156,13 @@ export const ProjectNotesTodoPanel: React.FC = ({ const [contextReloadTick, setContextReloadTick] = React.useState(0); const notesHydratedRef = React.useRef(false); const lastSavedNotesRef = React.useRef(''); + const todoPanelHeight = useUIStore((state) => state.todoPanelHeight); + const setTodoPanelHeight = useUIStore((state) => state.setTodoPanelHeight); + const notesPanelHeight = useUIStore((state) => state.notesPanelHeight); + const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight); + const [isTodoPanelResizing, setIsTodoPanelResizing] = React.useState(false); + const todoPanelStartYRef = React.useRef(0); + const todoPanelStartHeightRef = React.useRef(todoPanelHeight); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const createSession = useSessionUIStore((state) => state.createSession); @@ -102,6 +174,7 @@ export const ProjectNotesTodoPanel: React.FC = ({ const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); + const padding = useUIStore((state) => state.padding); const persistProjectData = React.useCallback( async (nextNotes: string, nextTodos: OpenChamberProjectTodoItem[]) => { @@ -191,6 +264,53 @@ export const ProjectNotesTodoPanel: React.FC = ({ }; }, [projectRef]); + React.useEffect(() => { + if (todos.length < 7) { + return; + } + const targetHeight = getPanelHeightForItems(todos.length, padding); + const minHeight = getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS; + if (todoPanelHeight < minHeight || todoPanelHeight > targetHeight) { + setTodoPanelHeight(targetHeight); + } + }, [todos.length, padding, todoPanelHeight, setTodoPanelHeight]); + + React.useEffect(() => { + if (!isTodoPanelResizing) { + return; + } + + const handlePointerMove = (event: PointerEvent) => { + const delta = event.clientY - todoPanelStartYRef.current; + const nextHeight = Math.min( + getEffectiveItemHeight(padding) * TODO_PANEL_MAX_ITEMS, + Math.max(getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS, todoPanelStartHeightRef.current + delta) + ); + setTodoPanelHeight(nextHeight); + }; + + const handlePointerEnd = () => { + setIsTodoPanelResizing(false); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerEnd, { once: true }); + window.addEventListener('pointercancel', handlePointerEnd, { once: true }); + + return () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerEnd); + window.removeEventListener('pointercancel', handlePointerEnd); + }; + }, [isTodoPanelResizing, padding, setTodoPanelHeight]); + + const handleTodoPanelResizeStart = React.useCallback((event: React.PointerEvent) => { + setIsTodoPanelResizing(true); + todoPanelStartYRef.current = event.clientY; + todoPanelStartHeightRef.current = todoPanelHeight; + event.preventDefault(); + }, [todoPanelHeight]); + const handleNotesBlur = React.useCallback(() => { lastSavedNotesRef.current = notes; void persistProjectData(notes, todos); @@ -274,6 +394,28 @@ export const ProjectNotesTodoPanel: React.FC = ({ void persistProjectData(notes, nextTodos); }, [notes, persistProjectData, todos]); + const handleTodoReorder = React.useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const oldIndex = todos.findIndex((todo) => todo.id === active.id); + const newIndex = todos.findIndex((todo) => todo.id === over.id); + if (oldIndex === -1 || newIndex === -1) { + return; + } + const nextTodos = arrayMove(todos, oldIndex, newIndex); + setTodos(nextTodos); + void persistProjectData(notes, nextTodos); + }, + [notes, persistProjectData, todos] + ); + + const todoSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) + ); + const todoInputValue = newTodoText.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH); const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0); @@ -441,12 +583,55 @@ export const ProjectNotesTodoPanel: React.FC = ({ [deletingPlanId, projectRef, t] ); - const handleTriggerUploadPlan = React.useCallback(() => { + const handleTriggerUploadPlan = React.useCallback(async () => { if (!projectRef || isImportingPlan) { return; } - planFileInputRef.current?.click(); - }, [isImportingPlan, projectRef]); + const result = await requestFileAccess({ + defaultPath: projectRef.path, + filters: [ + { name: 'Plan files', extensions: ['md', 'markdown', 'txt'] }, + { name: 'All files', extensions: ['*'] }, + ], + }); + if (result.success && result.path) { + setIsImportingPlan(true); + try { + const params = new URLSearchParams({ + path: result.path, + allowOutsideWorkspace: 'true', + }); + const response = await fetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' }); + if (!response.ok) { + toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed')); + return; + } + const text = await response.text(); + if (!text.trim()) { + toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty')); + return; + } + const fallbackTitle = result.path.split('/').pop()?.replace(/\.(md|markdown|txt)$/i, '').trim() || ''; + const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle); + if (!created) { + toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed')); + return; + } + window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { + detail: { projectId: projectRef.id }, + })); + toast.success(t('rightSidebar.contextNotesTodo.toast.planImported')); + } catch (error) { + const description = error instanceof Error ? error.message : undefined; + toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined); + } finally { + setIsImportingPlan(false); + } + } else if (result.error === 'Native file picker not available') { + // Fall back to HTML file input for web/non-desktop runtimes + planFileInputRef.current?.click(); + } + }, [isImportingPlan, projectRef, t]); const handleUploadPlanFile = React.useCallback( async (file: File | null) => { @@ -523,7 +708,8 @@ export const ProjectNotesTodoPanel: React.FC = ({ onChange={(event) => setNotes(event.target.value.slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH))} onBlur={handleNotesBlur} placeholder={t('rightSidebar.contextNotesTodo.notes.placeholder')} - className="min-h-28 max-h-80 resize-none" + resizedHeight={notesPanelHeight} + onResizeHeightChange={setNotesPanelHeight} useScrollShadow scrollShadowSize={56} disabled={isLoading} @@ -579,86 +765,141 @@ export const ProjectNotesTodoPanel: React.FC = ({ -
    +
    {todos.length === 0 ? (

    {t('rightSidebar.contextNotesTodo.todo.empty')}

    ) : ( -
      - {todos.map((todo) => { - const isExpandedTodo = expandedTodoIds.has(todo.id); - return ( -
    • -
      - handleToggleTodo(todo.id, checked)} - ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })} - /> -
      - -
      - - - - - - - handleSendToCurrentSession(todo.text)}> - {t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')} - - handleSendToNewSession(todo.id, todo.text)}> - {t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')} - - void handleSendToNewWorktreeSession(todo.id, todo.text)} - disabled={!canCreateWorktree} - > - {t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')} - - - -
      -
    • - ); - })} -
    + + todo.id)} + strategy={verticalListSortingStrategy} + > +
      + {todos.map((todo) => { + const isExpandedTodo = expandedTodoIds.has(todo.id); + return ( + + {(dragHandleProps) => ( +
      + +
      + handleToggleTodo(todo.id, checked)} + ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })} + /> +
      + +
      + + + + + + + handleSendToCurrentSession(todo.text)}> + {t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')} + + handleSendToNewSession(todo.id, todo.text)}> + {t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')} + + void handleSendToNewWorktreeSession(todo.id, todo.text)} + disabled={!canCreateWorktree} + > + {t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')} + + + +
      +
      + )} +
      + ); + })} +
    +
    +
    )}
    + {todos.length >= 7 && ( +
    + )}
    diff --git a/packages/ui/src/components/ui/textarea.tsx b/packages/ui/src/components/ui/textarea.tsx index 79cea9a4..74c85d71 100644 --- a/packages/ui/src/components/ui/textarea.tsx +++ b/packages/ui/src/components/ui/textarea.tsx @@ -11,6 +11,8 @@ type TextareaProps = React.ComponentProps<"textarea"> & { useScrollShadow?: boolean; scrollShadowSize?: number; hasError?: boolean; + resizedHeight?: number | null; + onResizeHeightChange?: (height: number) => void; /** * AlignUI "simple" mode: render a bare textarea (no compound wrapper). * Used for chat composer or anywhere the textarea is embedded inside an @@ -68,6 +70,8 @@ const Textarea = React.forwardRef( useScrollShadow = false, scrollShadowSize, hasError, + resizedHeight: controlledResizedHeight, + onResizeHeightChange, disabled, simple, endSlot, @@ -79,6 +83,7 @@ const Textarea = React.forwardRef( const wrapperRef = React.useRef(null); const dragStateRef = React.useRef<{ startY: number; startHeight: number } | null>(null); const [resizedHeight, setResizedHeight] = React.useState(null); + const effectiveResizedHeight = controlledResizedHeight ?? resizedHeight; const handleResizeStart = React.useCallback((event: React.PointerEvent) => { const wrapper = wrapperRef.current; @@ -94,7 +99,12 @@ const Textarea = React.forwardRef( const state = dragStateRef.current; if (!state) return; const next = state.startHeight + (moveEvent.clientY - state.startY); - setResizedHeight(Math.max(82, next)); + const nextHeight = Math.max(82, next); + if (onResizeHeightChange) { + onResizeHeightChange(nextHeight); + } else { + setResizedHeight(nextHeight); + } }; const onUp = () => { dragStateRef.current = null; @@ -106,7 +116,7 @@ const Textarea = React.forwardRef( target.addEventListener('pointerup', onUp); target.addEventListener('pointercancel', onUp); event.preventDefault(); - }, []); + }, [onResizeHeightChange]); const focusInnerTextarea = React.useCallback((event: React.PointerEvent) => { // Clicking the wrapper chrome (below/around the textarea) should focus it. @@ -152,7 +162,7 @@ const Textarea = React.forwardRef(
    } + options?: { filters?: Array<{ name: string; extensions: string[] }>; defaultPath?: string } ): Promise<{ success: boolean; path?: string; error?: string }> => { if (isTauriShell() && isDesktopLocalOriginActive()) { try { @@ -379,6 +379,7 @@ export const requestFileAccess = async ( multiple: false, title: 'Select File', ...(options?.filters ? { filters: options.filters } : {}), + ...(options?.defaultPath ? { defaultPath: options.defaultPath } : {}), }); if (!selected || typeof selected !== 'string') { return { success: false, error: 'File selection cancelled' }; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index f6fc69d8..bb7ec5d6 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1034,6 +1034,8 @@ export const dict = { 'rightSidebar.contextNotesTodo.todo.actions.expand': 'Expand todo "{text}"', 'rightSidebar.contextNotesTodo.todo.actions.delete': 'Delete "{text}"', 'rightSidebar.contextNotesTodo.todo.actions.send': 'Send "{text}"', + 'rightSidebar.contextNotesTodo.todo.actions.reorder': 'Reorder "{text}"', + 'rightSidebar.contextNotesTodo.todo.resizeAria': 'Resize todo list', 'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': 'Send to current session', 'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': 'Send to new session', 'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': 'Send to new worktree session', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index b3383007..60436fa9 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1000,6 +1000,8 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.todo.actions.expand": "Expandir tarea pendiente \"{text}\"", "rightSidebar.contextNotesTodo.todo.actions.delete": "Eliminar \"{text}\"", "rightSidebar.contextNotesTodo.todo.actions.send": "Enviar \"{text}\"", + "rightSidebar.contextNotesTodo.todo.actions.reorder": "Reordenar \"{text}\"", + "rightSidebar.contextNotesTodo.todo.resizeAria": "Redimensionar lista de tareas", "rightSidebar.contextNotesTodo.todo.sendMenu.currentSession": "Enviar a la sesión actual", "rightSidebar.contextNotesTodo.todo.sendMenu.newSession": "Enviar a una nueva sesión", "rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession": "Enviar a una nueva sesión de worktree", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 6d7e1ad9..d960df3b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1037,6 +1037,8 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.todo.actions.expand': '펼치기 todo "{text}"', 'rightSidebar.contextNotesTodo.todo.actions.delete': '"{text}" 삭제', 'rightSidebar.contextNotesTodo.todo.actions.send': '보내기 "{text}"', + 'rightSidebar.contextNotesTodo.todo.actions.reorder': '재정렬 "{text}"', + 'rightSidebar.contextNotesTodo.todo.resizeAria': '할 일 목록 크기 조정', 'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': '현재 세션으로 보내기', 'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': '새 세션으로 보내기', 'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': '새 워크트리 세션으로 보내기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index c714d764..85d51736 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1930,6 +1930,8 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.todo.actions.expand': 'Rozwiń zadanie „{text}”', 'rightSidebar.contextNotesTodo.todo.actions.markComplete': 'Oznacz „{text}” jako ukończone', 'rightSidebar.contextNotesTodo.todo.actions.send': 'Wyślij „{text}”', + 'rightSidebar.contextNotesTodo.todo.actions.reorder': 'Zmień kolejność "{text}"', + 'rightSidebar.contextNotesTodo.todo.resizeAria': 'Zmień rozmiar listy zadań', 'rightSidebar.contextNotesTodo.todo.addAria': 'Dodaj zadanie', 'rightSidebar.contextNotesTodo.todo.clearCompleted': 'Wyczyść ukończone', 'rightSidebar.contextNotesTodo.todo.empty': 'Brak zadań. Dodaj krótką checklistę dla tego projektu.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 02ceb8ba..a0ac8781 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1000,6 +1000,8 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.todo.actions.expand": "Expandir tarefa pendente \"{text}\"", "rightSidebar.contextNotesTodo.todo.actions.delete": "Excluir \"{text}\"", "rightSidebar.contextNotesTodo.todo.actions.send": "Enviar \"{text}\"", + "rightSidebar.contextNotesTodo.todo.actions.reorder": "Reordenar \"{text}\"", + "rightSidebar.contextNotesTodo.todo.resizeAria": "Redimensionar lista de tarefas", "rightSidebar.contextNotesTodo.todo.sendMenu.currentSession": "Enviar à sessão atual", "rightSidebar.contextNotesTodo.todo.sendMenu.newSession": "Enviar a uma nova sessão", "rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession": "Enviar a uma nova sessão de worktree", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 8ad118cf..fc9d79ee 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1000,6 +1000,8 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.todo.actions.expand": "Розгорнути завдання \"{text}\"", "rightSidebar.contextNotesTodo.todo.actions.delete": "Видалити \"{text}\"", "rightSidebar.contextNotesTodo.todo.actions.send": "Надіслати \"{text}\"", + "rightSidebar.contextNotesTodo.todo.actions.reorder": "Змінити порядок \"{text}\"", + "rightSidebar.contextNotesTodo.todo.resizeAria": "Змінити розмір списку завдань", "rightSidebar.contextNotesTodo.todo.sendMenu.currentSession": "Надіслати до поточної сесії", "rightSidebar.contextNotesTodo.todo.sendMenu.newSession": "Надіслати до нової сесії", "rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession": "Надіслати до нової сесії в worktree", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 373b29b4..6e9c299e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1000,6 +1000,8 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.todo.actions.expand': '展开待办“{text}”', 'rightSidebar.contextNotesTodo.todo.actions.delete': '删除“{text}”', 'rightSidebar.contextNotesTodo.todo.actions.send': '发送“{text}”', + 'rightSidebar.contextNotesTodo.todo.actions.reorder': '重新排序"{text}"', + 'rightSidebar.contextNotesTodo.todo.resizeAria': '调整待办列表大小', 'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': '发送到当前会话', 'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': '发送到新会话', 'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': '发送到新 worktree 会话', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 552b37b8..b16465f5 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -496,6 +496,8 @@ interface UIStore { isBottomTerminalExpanded: boolean; bottomTerminalHeight: number; hasManuallyResizedBottomTerminal: boolean; + notesPanelHeight: number; + todoPanelHeight: number; isSessionSwitcherOpen: boolean; isSessionDropdownOpen: boolean; activeMainTab: MainTab; @@ -627,6 +629,8 @@ interface UIStore { setBottomTerminalOpen: (open: boolean) => void; setBottomTerminalExpanded: (expanded: boolean) => void; setBottomTerminalHeight: (height: number) => void; + setNotesPanelHeight: (height: number) => void; + setTodoPanelHeight: (height: number) => void; setSessionSwitcherOpen: (open: boolean) => void; setSessionDropdownOpen: (open: boolean) => void; setActiveMainTab: (tab: MainTab) => void; @@ -760,6 +764,8 @@ export const useUIStore = create()( isBottomTerminalExpanded: false, bottomTerminalHeight: 300, hasManuallyResizedBottomTerminal: false, + notesPanelHeight: 112, + todoPanelHeight: 259, isSessionSwitcherOpen: false, isSessionDropdownOpen: false, activeMainTab: 'chat', @@ -1291,6 +1297,14 @@ export const useUIStore = create()( set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true }); }, + setNotesPanelHeight: (height) => { + set({ notesPanelHeight: height }); + }, + + setTodoPanelHeight: (height) => { + set({ todoPanelHeight: height }); + }, + setSessionSwitcherOpen: (open) => { set({ isSessionSwitcherOpen: open }); }, @@ -1955,13 +1969,23 @@ export const useUIStore = create()( { name: 'ui-store', storage: createJSONStorage(() => getSafeStorage()), - version: 8, + version: 9, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; } const state = persistedState as Record; + // v8 -> v9: initialize notes/todo panel height fields + if (version < 9) { + if (typeof state.notesPanelHeight !== 'number' || !Number.isFinite(state.notesPanelHeight)) { + state.notesPanelHeight = 112; + } + if (typeof state.todoPanelHeight !== 'number' || !Number.isFinite(state.todoPanelHeight)) { + state.todoPanelHeight = 259; + } + } + // v0 -> v1: reset legacy notification templates if (version < 1) { if (isLegacyDefaultTemplates(state.notificationTemplates)) { @@ -2046,6 +2070,8 @@ export const useUIStore = create()( isBottomTerminalOpen: state.isBottomTerminalOpen, isBottomTerminalExpanded: state.isBottomTerminalExpanded, bottomTerminalHeight: state.bottomTerminalHeight, + notesPanelHeight: state.notesPanelHeight, + todoPanelHeight: state.todoPanelHeight, isSessionSwitcherOpen: state.isSessionSwitcherOpen, activeMainTab: state.activeMainTab, sidebarSection: state.sidebarSection,