feat(ui): context panel enhancements — resizable panels, drag-and-drop todo ordering, and persistent sizes (#1269)

* 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Ç <ermanhavuc@gmail.com>

* fix(ui): harden context panel resizing and import

---------

Signed-off-by: Erman HAVUÇ <ermanhavuc@gmail.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Erman HAVUÇ
2026-05-17 23:26:26 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 631905764e
commit 6369cf76a7
12 changed files with 377 additions and 82 deletions
+3
View File
@@ -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')
@@ -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<typeof useSortable>['attributes'];
listeners: ReturnType<typeof useSortable>['listeners'];
setActivatorNodeRef: ReturnType<typeof useSortable>['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 (
<li
ref={setNodeRef}
style={{
transform: DndCSS.Transform.toString(transform),
transition,
}}
className={cn(isDragging && 'opacity-60')}
>
{children({ attributes, listeners, setActivatorNodeRef, isDragging })}
</li>
);
};
export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef,
projectLabel,
@@ -91,6 +156,13 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
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<ProjectNotesTodoPanelProps> = ({
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<ProjectNotesTodoPanelProps> = ({
};
}, [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<ProjectNotesTodoPanelProps> = ({
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<ProjectNotesTodoPanelProps> = ({
[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<ProjectNotesTodoPanelProps> = ({
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<ProjectNotesTodoPanelProps> = ({
</button>
</div>
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-background/40">
<div
className={cn(
'overflow-y-auto rounded-lg border border-border/60 bg-background/40',
isTodoPanelResizing && 'transition-none'
)}
style={
todos.length < 7
? undefined
: {
height: `${todoPanelHeight}px`,
maxHeight: `${todoPanelHeight}px`,
}
}
>
{todos.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.todo.empty')}
</p>
) : (
<ul className="divide-y divide-border/50">
{todos.map((todo) => {
const isExpandedTodo = expandedTodoIds.has(todo.id);
return (
<li key={todo.id} className="flex items-start gap-1.5 px-2.5 py-1.5">
<div className="flex h-6 items-center">
<Checkbox
checked={todo.completed}
onChange={(checked) => handleToggleTodo(todo.id, checked)}
ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })}
/>
</div>
<button
type="button"
onClick={() => handleToggleTodoExpanded(todo.id)}
className={cn(
'block min-h-6 min-w-0 flex-1 bg-transparent p-0 text-left typography-ui-label leading-6 text-foreground',
isExpandedTodo ? 'whitespace-normal break-words' : 'overflow-hidden text-ellipsis whitespace-nowrap',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
todo.completed && 'text-muted-foreground line-through'
)}
title={isExpandedTodo ? undefined : todo.text}
aria-label={
isExpandedTodo
? t('rightSidebar.contextNotesTodo.todo.actions.collapse', { text: todo.text })
: t('rightSidebar.contextNotesTodo.todo.actions.expand', { text: todo.text })
}
>
{todo.text}
</button>
<div className="flex h-6 items-center gap-0.5">
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={sendingTodoId === todo.id}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
>
<Icon name="send-plane" className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.id, todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
<DndContext
sensors={todoSensors}
collisionDetection={closestCenter}
onDragEnd={handleTodoReorder}
>
<SortableContext
items={todos.map((todo) => todo.id)}
strategy={verticalListSortingStrategy}
>
<ul className="divide-y divide-border/50">
{todos.map((todo) => {
const isExpandedTodo = expandedTodoIds.has(todo.id);
return (
<SortableTodoItem key={todo.id} id={todo.id}>
{(dragHandleProps) => (
<div className="flex items-start gap-1.5 px-2.5 py-1.5">
<button
type="button"
ref={dragHandleProps.setActivatorNodeRef}
{...dragHandleProps.attributes}
{...dragHandleProps.listeners}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
className="flex h-6 w-4 flex-shrink-0 touch-none items-center justify-center text-muted-foreground hover:text-foreground"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
>
<Icon name="draggable" className="h-3.5 w-3.5" />
</button>
<div className="flex h-6 items-center">
<Checkbox
checked={todo.completed}
onChange={(checked) => handleToggleTodo(todo.id, checked)}
ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })}
/>
</div>
<button
type="button"
onClick={() => handleToggleTodoExpanded(todo.id)}
className={cn(
'block min-h-6 min-w-0 flex-1 bg-transparent p-0 text-left typography-ui-label leading-6 text-foreground',
isExpandedTodo ? 'whitespace-normal break-words' : 'overflow-hidden text-ellipsis whitespace-nowrap',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
todo.completed && 'text-muted-foreground line-through'
)}
title={isExpandedTodo ? undefined : todo.text}
aria-label={
isExpandedTodo
? t('rightSidebar.contextNotesTodo.todo.actions.collapse', { text: todo.text })
: t('rightSidebar.contextNotesTodo.todo.actions.expand', { text: todo.text })
}
>
{todo.text}
</button>
<div className="flex h-6 items-center gap-0.5">
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={sendingTodoId === todo.id}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
>
<Icon name="send-plane" className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.id, todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</SortableTodoItem>
);
})}
</ul>
</SortableContext>
</DndContext>
)}
</div>
{todos.length >= 7 && (
<div
className={cn(
'h-[3px] w-full cursor-row-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
isTodoPanelResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handleTodoPanelResizeStart}
role="separator"
aria-orientation="horizontal"
aria-label={t('rightSidebar.contextNotesTodo.todo.resizeAria')}
/>
)}
</div>
<div className="space-y-2">
+13 -3
View File
@@ -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<HTMLTextAreaElement, TextareaProps>(
useScrollShadow = false,
scrollShadowSize,
hasError,
resizedHeight: controlledResizedHeight,
onResizeHeightChange,
disabled,
simple,
endSlot,
@@ -79,6 +83,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
const wrapperRef = React.useRef<HTMLDivElement>(null);
const dragStateRef = React.useRef<{ startY: number; startHeight: number } | null>(null);
const [resizedHeight, setResizedHeight] = React.useState<number | null>(null);
const effectiveResizedHeight = controlledResizedHeight ?? resizedHeight;
const handleResizeStart = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const wrapper = wrapperRef.current;
@@ -94,7 +99,12 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
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<HTMLTextAreaElement, TextareaProps>(
target.addEventListener('pointerup', onUp);
target.addEventListener('pointercancel', onUp);
event.preventDefault();
}, []);
}, [onResizeHeightChange]);
const focusInnerTextarea = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
// Clicking the wrapper chrome (below/around the textarea) should focus it.
@@ -152,7 +162,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<div
ref={wrapperRef}
onPointerDown={focusInnerTextarea}
style={resizedHeight !== null ? { height: `${resizedHeight}px` } : undefined}
style={effectiveResizedHeight !== null ? { height: `${effectiveResizedHeight}px` } : undefined}
className={cn(
"group/textarea relative flex w-full flex-col rounded-[var(--radius-xl)] bg-[var(--surface-elevated)] pb-2.5",
"ring-1 ring-inset ring-border/60 transition duration-200 ease-out",
+2 -1
View File
@@ -369,7 +369,7 @@ export const requestDirectoryAccess = async (
};
export const requestFileAccess = async (
options?: { filters?: Array<{ name: string; extensions: string[] }> }
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' };
+2
View File
@@ -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',
+2
View File
@@ -1000,6 +1000,8 @@ export const dict: Record<I18nKey, string> = {
"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",
+2
View File
@@ -1037,6 +1037,8 @@ export const dict: Record<I18nKey, string> = {
'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': '새 워크트리 세션으로 보내기',
+2
View File
@@ -1930,6 +1930,8 @@ export const dict: Record<I18nKey, string> = {
'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.',
@@ -1000,6 +1000,8 @@ export const dict: Record<I18nKey, string> = {
"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",
+2
View File
@@ -1000,6 +1000,8 @@ export const dict: Record<I18nKey, string> = {
"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",
@@ -1000,6 +1000,8 @@ export const dict: Record<I18nKey, string> = {
'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 会话',
+27 -1
View File
@@ -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<UIStore>()(
isBottomTerminalExpanded: false,
bottomTerminalHeight: 300,
hasManuallyResizedBottomTerminal: false,
notesPanelHeight: 112,
todoPanelHeight: 259,
isSessionSwitcherOpen: false,
isSessionDropdownOpen: false,
activeMainTab: 'chat',
@@ -1291,6 +1297,14 @@ export const useUIStore = create<UIStore>()(
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<UIStore>()(
{
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<string, unknown>;
// 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<UIStore>()(
isBottomTerminalOpen: state.isBottomTerminalOpen,
isBottomTerminalExpanded: state.isBottomTerminalExpanded,
bottomTerminalHeight: state.bottomTerminalHeight,
notesPanelHeight: state.notesPanelHeight,
todoPanelHeight: state.todoPanelHeight,
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab,
sidebarSection: state.sidebarSection,