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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
631905764e
commit
6369cf76a7
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '새 워크트리 세션으로 보내기',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 会话',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user