From 98a6228ed937985f939fb88c745e0a6e07ef564d Mon Sep 17 00:00:00 2001 From: PostHog Code Date: Mon, 18 May 2026 09:58:26 +0000 Subject: [PATCH 1/3] feat(sidebar): add inline search for tasks list Adds a magnifying glass button to the Tasks section header (left of the filter menu). Clicking it swaps the "Tasks" label and search icon for an inline search input that autofocuses, while keeping the filter button visible. Typing filters the rendered task list (pinned, chronological, and by- project views) by title. Date group labels (Yesterday, This week, etc.) remain inline because they're derived from the filtered tasks. Keyboard: - ESC clears the query, exits search mode, and stopPropagation prevents it from bubbling to any agent-interruption handler - ArrowDown from the input focuses the first task row - ArrowUp/Down between task rows performs roving focus; ArrowUp from the first task returns focus to the input Generated-By: PostHog Code Task-Id: 79c010cc-9d8d-494b-b55e-9b44c64734a1 --- .../sidebar/components/TaskListView.tsx | 483 ++++++++++++------ 1 file changed, 324 insertions(+), 159 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 07960d98f..c3457d2e8 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -6,6 +6,7 @@ import { useMeQuery } from "@hooks/useMeQuery"; import { FunnelSimple as FunnelSimpleIcon, GitBranch, + MagnifyingGlass, } from "@phosphor-icons/react"; import { Button, @@ -24,7 +25,14 @@ import { normalizeRepoKey } from "@shared/utils/repo"; import { useNavigationStore } from "@stores/navigationStore"; import { getRelativeDateGroup } from "@utils/time"; import { motion } from "framer-motion"; -import { Fragment, useCallback, useEffect, useMemo } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import type { TaskData, TaskGroup } from "../hooks/useSidebarData"; import { useTaskPrStatus } from "../hooks/useTaskPrStatus"; import { useSidebarStore } from "../stores/sidebarStore"; @@ -227,6 +235,48 @@ function TaskFilterMenu() { ); } +function TaskSearchHeader({ + query, + onQueryChange, + onClose, + onArrowDown, + inputRef, +}: { + query: string; + onQueryChange: (value: string) => void; + onClose: () => void; + onArrowDown: () => void; + inputRef: React.RefObject; +}) { + return ( +
+ + onQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.stopPropagation(); + event.preventDefault(); + onClose(); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + onArrowDown(); + } + }} + className="min-w-0 flex-1 bg-transparent text-[13px] text-gray-12 leading-snug outline-none placeholder:text-gray-9" + /> + +
+ ); +} + export function TaskListView({ pinnedTasks, flatTasks, @@ -250,6 +300,87 @@ export function TaskListView({ const resetHistoryVisibleCount = useSidebarStore( (state) => state.resetHistoryVisibleCount, ); + const [isSearching, setIsSearching] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const taskListContainerRef = useRef(null); + + const normalizedQuery = isSearching ? searchQuery.trim().toLowerCase() : ""; + const matchesQuery = useCallback( + (task: TaskData) => + !normalizedQuery || task.title.toLowerCase().includes(normalizedQuery), + [normalizedQuery], + ); + + const visiblePinnedTasks = useMemo( + () => (normalizedQuery ? pinnedTasks.filter(matchesQuery) : pinnedTasks), + [pinnedTasks, normalizedQuery, matchesQuery], + ); + const visibleFlatTasks = useMemo( + () => (normalizedQuery ? flatTasks.filter(matchesQuery) : flatTasks), + [flatTasks, normalizedQuery, matchesQuery], + ); + const visibleGroupedTasks = useMemo(() => { + if (!normalizedQuery) return groupedTasks; + return groupedTasks + .map((group) => ({ + ...group, + tasks: group.tasks.filter(matchesQuery), + })) + .filter((group) => group.tasks.length > 0); + }, [groupedTasks, normalizedQuery, matchesQuery]); + + const focusableTaskButtons = useCallback(() => { + const rows = + taskListContainerRef.current?.querySelectorAll( + '[data-task-row=""]', + ); + if (!rows) return [] as HTMLButtonElement[]; + return Array.from(rows) + .map((row) => row.querySelector("button")) + .filter((el): el is HTMLButtonElement => el instanceof HTMLButtonElement); + }, []); + + const closeSearch = useCallback(() => { + setIsSearching(false); + setSearchQuery(""); + }, []); + + const handleArrowDownFromInput = useCallback(() => { + const [firstButton] = focusableTaskButtons(); + firstButton?.focus(); + }, [focusableTaskButtons]); + + const handleListKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!isSearching) return; + if (event.key === "Escape") { + event.stopPropagation(); + event.preventDefault(); + closeSearch(); + return; + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + const buttons = focusableTaskButtons(); + if (buttons.length === 0) return; + const active = document.activeElement; + if (!(active instanceof HTMLElement)) return; + const idx = buttons.findIndex( + (button) => button === active || button.contains(active), + ); + if (idx === -1) return; + event.preventDefault(); + if (event.key === "ArrowDown") { + const next = buttons[Math.min(idx + 1, buttons.length - 1)]; + next?.focus(); + } else if (idx === 0) { + searchInputRef.current?.focus(); + } else { + buttons[idx - 1]?.focus(); + } + }, + [isSearching, closeSearch, focusableTaskButtons], + ); const { folders } = useFolders(); const navigateToTaskInput = useNavigationStore( (state) => state.navigateToTaskInput, @@ -282,7 +413,7 @@ export function TaskListView({ const dateGroupedTasks = useMemo(() => { const groups: { label: string | null; tasks: TaskData[] }[] = []; - for (const task of flatTasks) { + for (const task of visibleFlatTasks) { const label = getRelativeDateGroup(task[timestampKey]); const last = groups[groups.length - 1]; if (last && last.label === label) { @@ -292,151 +423,18 @@ export function TaskListView({ } } return groups; - }, [flatTasks, timestampKey]); + }, [visibleFlatTasks, timestampKey]); return ( - - {pinnedTasks.length > 0 && ( - <> - - {pinnedTasks.map((task) => ( - onTaskClick(task.id)} - onDoubleClick={() => onTaskDoubleClick(task.id)} - onContextMenu={(e, isPinned) => - onTaskContextMenu(task.id, e, isPinned) - } - onArchive={() => onTaskArchive(task.id)} - onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => onTaskEditSubmit(task.id, newTitle)} - onEditCancel={onTaskEditCancel} - timestamp={task[timestampKey]} - /> - ))} - - )} - - } /> - - {pinnedTasks.length === 0 && - flatTasks.length === 0 && - groupedTasks.length === 0 ? ( -
- - No tasks yet - {!isOnTaskInput && ( - navigateToTaskInput()} - whileHover={{ scale: 1.05, backgroundColor: "var(--gray-4)" }} - whileTap={{ scale: 0.97 }} - > - Start building - - )} -
- ) : organizeMode === "by-project" ? ( - - - {groupedTasks.map((group, index) => { - const isExpanded = !collapsedSections.has(group.id); - const folder = folders.find( - (f) => - (f.remoteUrl && - normalizeRepoKey(f.remoteUrl).toLowerCase() === group.id) || - f.path === group.id, - ); - const groupFolderId = - folder?.id ?? group.tasks.find((t) => t.folderId)?.folderId; - return ( - - } - isExpanded={isExpanded} - onToggle={() => toggleSection(group.id)} - addSpacingBefore={false} - tooltipContent={folder?.path ?? group.id} - onNewTask={() => { - if (groupFolderId) { - navigateToTaskInput(groupFolderId); - } else { - navigateToTaskInput(); - } - }} - newTaskTooltip={`Start new task in ${folder?.name ?? group.name}`} - > - {group.tasks.map((task) => ( - onTaskClick(task.id)} - onDoubleClick={() => onTaskDoubleClick(task.id)} - onContextMenu={(e, isPinned) => - onTaskContextMenu(task.id, e, isPinned) - } - onArchive={() => onTaskArchive(task.id)} - onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) - } - onEditCancel={onTaskEditCancel} - timestamp={task[timestampKey]} - depth={1} - /> - ))} - - - ); - })} - - - ) : ( - - {dateGroupedTasks.map((group, groupIndex) => ( - - {group.label && } - {group.tasks.map((task) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: keydown delegates roving focus across child task buttons +
+ + {visiblePinnedTasks.length > 0 && ( + <> + + {visiblePinnedTasks.map((task) => ( +
- ))} - - ))} - {hasMore && ( -
- + + + } + /> + )} + + {visiblePinnedTasks.length === 0 && + visibleFlatTasks.length === 0 && + visibleGroupedTasks.length === 0 ? ( +
+ + No tasks yet + {!isOnTaskInput && ( + navigateToTaskInput()} + whileHover={{ scale: 1.05, backgroundColor: "var(--gray-4)" }} + whileTap={{ scale: 0.97 }} > - Show more - -
- )} - - )} - + Start building + + )} +
+ ) : organizeMode === "by-project" ? ( + + + {visibleGroupedTasks.map((group, index) => { + const isExpanded = !collapsedSections.has(group.id); + const folder = folders.find( + (f) => + (f.remoteUrl && + normalizeRepoKey(f.remoteUrl).toLowerCase() === + group.id) || + f.path === group.id, + ); + const groupFolderId = + folder?.id ?? group.tasks.find((t) => t.folderId)?.folderId; + return ( + + } + isExpanded={isExpanded} + onToggle={() => toggleSection(group.id)} + addSpacingBefore={false} + tooltipContent={folder?.path ?? group.id} + onNewTask={() => { + if (groupFolderId) { + navigateToTaskInput(groupFolderId); + } else { + navigateToTaskInput(); + } + }} + newTaskTooltip={`Start new task in ${folder?.name ?? group.name}`} + > + {group.tasks.map((task) => ( +
+ onTaskClick(task.id)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => + onTaskContextMenu(task.id, e, isPinned) + } + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => + onTaskEditSubmit(task.id, newTitle) + } + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + depth={1} + /> +
+ ))} +
+
+ ); + })} +
+
+ ) : ( + + {dateGroupedTasks.map((group, groupIndex) => ( + + {group.label && } + {group.tasks.map((task) => ( +
+ onTaskClick(task.id)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => + onTaskContextMenu(task.id, e, isPinned) + } + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => + onTaskEditSubmit(task.id, newTitle) + } + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + /> +
+ ))} +
+ ))} + {hasMore && ( +
+ +
+ )} +
+ )} + +
); } From 1dede806fc2b2fdebfd4c6c54c3930aafdc6452b Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Mon, 18 May 2026 13:16:53 +0100 Subject: [PATCH 2/3] feat(sidebar): scoped command palette for task search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline task-search header with a Search nav item and a scoped command palette. - Sidebar: add "Search" nav item => opens cmd palette in tasks scope; Tasks-section search icon button does the same - Command palette: add a quill Select to the input that switches scope Commands => Tasks; Tab cycles scope - Tasks scope lists every task; selecting one navigates to it - Nav items reveal their keyboard shortcut (⌘K / ⌘N / ⌘I) on hover - Inbox: tooltip-shortcut => inline hover Kbd; Alpha badge => quill Badge Co-Authored-By: Claude Opus 4.7 (1M context) --- .../command/components/CommandKeyHints.tsx | 6 + .../command/components/CommandMenu.tsx | 122 ++++- .../sidebar/components/SidebarItem.tsx | 90 ++-- .../sidebar/components/SidebarMenu.tsx | 11 + .../sidebar/components/TaskListView.tsx | 505 +++++++----------- .../sidebar/components/items/HomeItem.tsx | 18 +- .../sidebar/components/items/SearchItem.tsx | 20 + .../components/items/SidebarKbdHint.tsx | 21 + .../src/renderer/stores/commandMenuStore.ts | 17 +- apps/code/src/shared/types/analytics.ts | 3 +- 10 files changed, 428 insertions(+), 385 deletions(-) create mode 100644 apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx create mode 100644 apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx diff --git a/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx b/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx index c06f67b35..8a74095b6 100644 --- a/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx +++ b/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx @@ -16,6 +16,12 @@ export function CommandKeyHints() { select
+
+ + Tab + + switch scope +
Esc diff --git a/apps/code/src/renderer/features/command/components/CommandMenu.tsx b/apps/code/src/renderer/features/command/components/CommandMenu.tsx index 3fafe99f4..8d121d65c 100644 --- a/apps/code/src/renderer/features/command/components/CommandMenu.tsx +++ b/apps/code/src/renderer/features/command/components/CommandMenu.tsx @@ -3,6 +3,7 @@ import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; import { useFolders } from "@features/folders/hooks/useFolders"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; +import { useTasks } from "@features/tasks/hooks/useTasks"; import { Autocomplete, AutocompleteCollection, @@ -14,12 +15,19 @@ import { AutocompleteStatus, Dialog, DialogContent, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, } from "@posthog/quill"; import { DesktopIcon, FileTextIcon, GearIcon, HomeIcon, + MagnifyingGlassIcon, MoonIcon, SunIcon, ViewVerticalIcon, @@ -28,6 +36,10 @@ import { ANALYTICS_EVENTS, type CommandMenuAction, } from "@shared/types/analytics"; +import { + type CommandMenuScope, + useCommandMenuStore, +} from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; import { useThemeStore } from "@stores/themeStore"; import { track } from "@utils/analytics"; @@ -49,8 +61,52 @@ type Command = { type CommandSection = { label: string; items: Command[] }; +const SCOPE_LABELS: Record = { + commands: "Commands", + tasks: "Tasks", +}; + +const SCOPE_PLACEHOLDERS: Record = { + commands: "Type a command…", + tasks: "Search tasks…", +}; + +/** Scope picker rendered inline at the end of the command input. */ +function ScopeSelect({ + scope, + onScopeChange, +}: { + scope: CommandMenuScope; + onScopeChange: (scope: CommandMenuScope) => void; +}) { + return ( + + ); +} + export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { - const { navigateToTaskInput } = useNavigationStore(); + const { navigateToTaskInput, navigateToTask } = useNavigationStore(); const openSettingsDialog = useSettingsDialogStore((state) => state.open); const closeSettingsDialog = useSettingsDialogStore((state) => state.close); const { folders } = useFolders(); @@ -63,6 +119,9 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const getReviewMode = useReviewNavigationStore( (state) => state.getReviewMode, ); + const scope = useCommandMenuStore((state) => state.scope); + const setScope = useCommandMenuStore((state) => state.setScope); + const { data: tasks = [] } = useTasks(); const [query, setQuery] = useState(""); const [systemPrefersDark, setSystemPrefersDark] = useState( () => window.matchMedia("(prefers-color-scheme: dark)").matches, @@ -93,6 +152,16 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { } }, [open]); + // Clear the query whenever the scope flips so stale text doesn't carry over. + // biome-ignore lint/correctness/useExhaustiveDependencies: reset on scope change only + useEffect(() => { + setQuery(""); + }, [scope]); + + const cycleScope = useCallback(() => { + setScope(scope === "commands" ? "tasks" : "commands"); + }, [scope, setScope]); + const themeOptions = useMemo(() => { const options: Command[] = []; if (theme !== "light") { @@ -131,7 +200,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { return options; }, [theme, setTheme, systemPrefersDark]); - const sections = useMemo(() => { + const commandSections = useMemo(() => { const navigation: Command[] = [ { id: "home", @@ -214,6 +283,27 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { openReviewPanel, ]); + const taskSections = useMemo(() => { + if (tasks.length === 0) return []; + return [ + { + label: "Tasks", + items: tasks.map((task) => ({ + id: `task-${task.id}`, + label: task.title, + icon: , + action: "open-task" as CommandMenuAction, + onRun: () => { + closeSettingsDialog(); + navigateToTask(task); + }, + })), + }, + ]; + }, [tasks, navigateToTask, closeSettingsDialog]); + + const sections = scope === "tasks" ? taskSections : commandSections; + const allCommands = useMemo( () => sections.flatMap((s) => s.items), [sections], @@ -254,15 +344,33 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }} > + onKeyDown={(event) => { + // Tab cycles between the Commands / Tasks scopes instead of + // moving focus out of the input. + if (event.key === "Tab") { + event.preventDefault(); + event.stopPropagation(); + cycleScope(); + } + }} + > + + - No commands match "{query}" - + scope === "tasks" ? ( + + + No tasks match "{query}" + + ) : ( + + No commands match "{query}" + + ) } /> diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx index 0a72aeeca..ace31838c 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx @@ -1,6 +1,6 @@ import { Tooltip } from "@components/ui/Tooltip"; import { Button, cn } from "@posthog/quill"; -import { useCallback, useRef, useState } from "react"; +import { useRef, useState } from "react"; import type { SidebarItemAction } from "../types"; const INDENT_SIZE = 8; @@ -22,6 +22,42 @@ interface SidebarItemProps { disabled?: boolean; } +/** + * Label that truncates with an ellipsis and reveals the full text in a + * tooltip on hover when it's actually clipped. Truncation is scoped to this + * span so sibling content (e.g. `endContent`) is never hidden. + */ +function SidebarItemLabel({ label }: { label: React.ReactNode }) { + const ref = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + const canTooltip = typeof label === "string" || typeof label === "number"; + + const span = ( + // biome-ignore lint/a11y/noStaticElementInteractions: hover handlers only drive a tooltip for truncated labels + { + const el = ref.current; + if (canTooltip && el && el.scrollWidth > el.clientWidth) { + setShowTooltip(true); + } + }} + onMouseLeave={() => setShowTooltip(false)} + > + {label} + + ); + + if (!canTooltip) return span; + + return ( + + {span} + + ); +} + export function SidebarItem({ depth, icon, @@ -36,46 +72,20 @@ export function SidebarItem({ endContent, disabled, }: SidebarItemProps) { - const labelRef = useRef(null); - const [showLabelTooltip, setShowLabelTooltip] = useState(false); - const canShowLabelTooltip = - typeof label === "string" || typeof label === "number"; - - const handleLabelMouseEnter = useCallback(() => { - const el = labelRef.current; - if (el && el.scrollWidth > el.clientWidth) { - setShowLabelTooltip(true); - } - }, []); - - const handleLabelMouseLeave = useCallback(() => { - setShowLabelTooltip(false); - }, []); - - const labelSpan = ( - // biome-ignore lint/a11y/noStaticElementInteractions: hover handlers only drive a visual tooltip for truncated labels - - {label} - - ); - return ( ); diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 8edd66f80..b435bddb5 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -20,6 +20,7 @@ import { ScrollArea, Separator } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; import type { Schemas } from "@renderer/api/generated"; import type { Task } from "@shared/types"; +import { useCommandMenuStore } from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; import { useQueryClient } from "@tanstack/react-query"; @@ -33,6 +34,7 @@ import { useSidebarStore } from "../stores/sidebarStore"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; import { McpServersItem } from "./items/McpServersItem"; +import { SearchItem } from "./items/SearchItem"; import { SetupItem } from "./items/SetupItem"; import { SkillsItem } from "./items/SkillsItem"; import { SidebarItem } from "./SidebarItem"; @@ -145,6 +147,11 @@ function SidebarMenuComponent() { navigateToSetup(); }; + const openCommandMenu = useCommandMenuStore((s) => s.open); + const handleSearchClick = () => { + openCommandMenu("tasks"); + }; + const handleTaskClick = (taskId: string) => { const task = taskMap.get(taskId); if (task) { @@ -325,6 +332,10 @@ function SidebarMenuComponent() { )} + + + + state.open); + return ( + + ); +} + function TaskFilterMenu() { const organizeMode = useSidebarStore((state) => state.organizeMode); const sortMode = useSidebarStore((state) => state.sortMode); @@ -235,48 +243,6 @@ function TaskFilterMenu() { ); } -function TaskSearchHeader({ - query, - onQueryChange, - onClose, - onArrowDown, - inputRef, -}: { - query: string; - onQueryChange: (value: string) => void; - onClose: () => void; - onArrowDown: () => void; - inputRef: React.RefObject; -}) { - return ( -
- - onQueryChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape") { - event.stopPropagation(); - event.preventDefault(); - onClose(); - } else if (event.key === "ArrowDown") { - event.preventDefault(); - onArrowDown(); - } - }} - className="min-w-0 flex-1 bg-transparent text-[13px] text-gray-12 leading-snug outline-none placeholder:text-gray-9" - /> - -
- ); -} - export function TaskListView({ pinnedTasks, flatTasks, @@ -300,87 +266,6 @@ export function TaskListView({ const resetHistoryVisibleCount = useSidebarStore( (state) => state.resetHistoryVisibleCount, ); - const [isSearching, setIsSearching] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const searchInputRef = useRef(null); - const taskListContainerRef = useRef(null); - - const normalizedQuery = isSearching ? searchQuery.trim().toLowerCase() : ""; - const matchesQuery = useCallback( - (task: TaskData) => - !normalizedQuery || task.title.toLowerCase().includes(normalizedQuery), - [normalizedQuery], - ); - - const visiblePinnedTasks = useMemo( - () => (normalizedQuery ? pinnedTasks.filter(matchesQuery) : pinnedTasks), - [pinnedTasks, normalizedQuery, matchesQuery], - ); - const visibleFlatTasks = useMemo( - () => (normalizedQuery ? flatTasks.filter(matchesQuery) : flatTasks), - [flatTasks, normalizedQuery, matchesQuery], - ); - const visibleGroupedTasks = useMemo(() => { - if (!normalizedQuery) return groupedTasks; - return groupedTasks - .map((group) => ({ - ...group, - tasks: group.tasks.filter(matchesQuery), - })) - .filter((group) => group.tasks.length > 0); - }, [groupedTasks, normalizedQuery, matchesQuery]); - - const focusableTaskButtons = useCallback(() => { - const rows = - taskListContainerRef.current?.querySelectorAll( - '[data-task-row=""]', - ); - if (!rows) return [] as HTMLButtonElement[]; - return Array.from(rows) - .map((row) => row.querySelector("button")) - .filter((el): el is HTMLButtonElement => el instanceof HTMLButtonElement); - }, []); - - const closeSearch = useCallback(() => { - setIsSearching(false); - setSearchQuery(""); - }, []); - - const handleArrowDownFromInput = useCallback(() => { - const [firstButton] = focusableTaskButtons(); - firstButton?.focus(); - }, [focusableTaskButtons]); - - const handleListKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (!isSearching) return; - if (event.key === "Escape") { - event.stopPropagation(); - event.preventDefault(); - closeSearch(); - return; - } - if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; - const buttons = focusableTaskButtons(); - if (buttons.length === 0) return; - const active = document.activeElement; - if (!(active instanceof HTMLElement)) return; - const idx = buttons.findIndex( - (button) => button === active || button.contains(active), - ); - if (idx === -1) return; - event.preventDefault(); - if (event.key === "ArrowDown") { - const next = buttons[Math.min(idx + 1, buttons.length - 1)]; - next?.focus(); - } else if (idx === 0) { - searchInputRef.current?.focus(); - } else { - buttons[idx - 1]?.focus(); - } - }, - [isSearching, closeSearch, focusableTaskButtons], - ); const { folders } = useFolders(); const navigateToTaskInput = useNavigationStore( (state) => state.navigateToTaskInput, @@ -413,7 +298,7 @@ export function TaskListView({ const dateGroupedTasks = useMemo(() => { const groups: { label: string | null; tasks: TaskData[] }[] = []; - for (const task of visibleFlatTasks) { + for (const task of flatTasks) { const label = getRelativeDateGroup(task[timestampKey]); const last = groups[groups.length - 1]; if (last && last.label === label) { @@ -423,18 +308,159 @@ export function TaskListView({ } } return groups; - }, [visibleFlatTasks, timestampKey]); + }, [flatTasks, timestampKey]); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: keydown delegates roving focus across child task buttons -
- - {visiblePinnedTasks.length > 0 && ( - <> - - {visiblePinnedTasks.map((task) => ( -
+ + {pinnedTasks.length > 0 && ( + <> + + {pinnedTasks.map((task) => ( + onTaskClick(task.id)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => + onTaskContextMenu(task.id, e, isPinned) + } + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => onTaskEditSubmit(task.id, newTitle)} + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + /> + ))} + + )} + + + + + + } + /> + + {pinnedTasks.length === 0 && + flatTasks.length === 0 && + groupedTasks.length === 0 ? ( +
+ + No tasks yet + {!isOnTaskInput && ( + navigateToTaskInput()} + whileHover={{ scale: 1.05, backgroundColor: "var(--gray-4)" }} + whileTap={{ scale: 0.97 }} + > + Start building + + )} +
+ ) : organizeMode === "by-project" ? ( + + + {groupedTasks.map((group, index) => { + const isExpanded = !collapsedSections.has(group.id); + const folder = folders.find( + (f) => + (f.remoteUrl && + normalizeRepoKey(f.remoteUrl).toLowerCase() === group.id) || + f.path === group.id, + ); + const groupFolderId = + folder?.id ?? group.tasks.find((t) => t.folderId)?.folderId; + return ( + + } + isExpanded={isExpanded} + onToggle={() => toggleSection(group.id)} + addSpacingBefore={false} + tooltipContent={folder?.path ?? group.id} + onNewTask={() => { + if (groupFolderId) { + navigateToTaskInput(groupFolderId); + } else { + navigateToTaskInput(); + } + }} + newTaskTooltip={`Start new task in ${folder?.name ?? group.name}`} + > + {group.tasks.map((task) => ( + onTaskClick(task.id)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => + onTaskContextMenu(task.id, e, isPinned) + } + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => + onTaskEditSubmit(task.id, newTitle) + } + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + depth={1} + /> + ))} + + + ); + })} + + + ) : ( + + {dateGroupedTasks.map((group, groupIndex) => ( + + {group.label && } + {group.tasks.map((task) => ( -
- ))} - - )} - - {isSearching ? ( - - ) : ( - - - - - } - /> - )} - - {visiblePinnedTasks.length === 0 && - visibleFlatTasks.length === 0 && - visibleGroupedTasks.length === 0 ? ( -
- - No tasks yet - {!isOnTaskInput && ( - + ))} + {hasMore && ( +
+
- ) : organizeMode === "by-project" ? ( - - - {visibleGroupedTasks.map((group, index) => { - const isExpanded = !collapsedSections.has(group.id); - const folder = folders.find( - (f) => - (f.remoteUrl && - normalizeRepoKey(f.remoteUrl).toLowerCase() === - group.id) || - f.path === group.id, - ); - const groupFolderId = - folder?.id ?? group.tasks.find((t) => t.folderId)?.folderId; - return ( - - } - isExpanded={isExpanded} - onToggle={() => toggleSection(group.id)} - addSpacingBefore={false} - tooltipContent={folder?.path ?? group.id} - onNewTask={() => { - if (groupFolderId) { - navigateToTaskInput(groupFolderId); - } else { - navigateToTaskInput(); - } - }} - newTaskTooltip={`Start new task in ${folder?.name ?? group.name}`} - > - {group.tasks.map((task) => ( -
- onTaskClick(task.id)} - onDoubleClick={() => onTaskDoubleClick(task.id)} - onContextMenu={(e, isPinned) => - onTaskContextMenu(task.id, e, isPinned) - } - onArchive={() => onTaskArchive(task.id)} - onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) - } - onEditCancel={onTaskEditCancel} - timestamp={task[timestampKey]} - depth={1} - /> -
- ))} -
-
- ); - })} -
-
- ) : ( - - {dateGroupedTasks.map((group, groupIndex) => ( - - {group.label && } - {group.tasks.map((task) => ( -
- onTaskClick(task.id)} - onDoubleClick={() => onTaskDoubleClick(task.id)} - onContextMenu={(e, isPinned) => - onTaskContextMenu(task.id, e, isPinned) - } - onArchive={() => onTaskArchive(task.id)} - onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) - } - onEditCancel={onTaskEditCancel} - timestamp={task[timestampKey]} - /> -
- ))} -
- ))} - {hasMore && ( -
- -
- )} -
- )} - -
+ Show more + +
+ )} + + )} + ); } diff --git a/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx index 5fcef26b0..7ca165a1c 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx @@ -1,12 +1,9 @@ -import { Badge } from "@components/ui/Badge"; import { Tooltip } from "@components/ui/Tooltip"; import { EnvelopeSimple, Plus } from "@phosphor-icons/react"; -import type { ButtonProps } from "@posthog/quill"; -import { - formatHotkey, - SHORTCUTS, -} from "@renderer/constants/keyboard-shortcuts"; +import { Badge, type ButtonProps } from "@posthog/quill"; +import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; import { SidebarItem } from "../SidebarItem"; +import { SidebarKbdHint } from "./SidebarKbdHint"; interface NewTaskItemProps { isActive: boolean; @@ -22,6 +19,7 @@ export function NewTaskItem({ isActive, onClick }: NewTaskItemProps) { label="New task" isActive={isActive} onClick={onClick} + endContent={} /> ); } @@ -45,7 +43,6 @@ export function InboxItem({ isActive, onClick, signalCount }: InboxItemProps) { ? `${signalCount} actionable report${signalCount === 1 ? "" : "s"} assigned to you` : "No actionable reports assigned to you yet" } - shortcut={formatHotkey(SHORTCUTS.INBOX)} side="right" >
@@ -72,7 +69,12 @@ export function InboxItem({ isActive, onClick, signalCount }: InboxItemProps) { } isActive={isActive} onClick={onClick} - endContent={Alpha} + endContent={ + <> + Alpha + + + } />
diff --git a/apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx new file mode 100644 index 000000000..99d68461b --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx @@ -0,0 +1,20 @@ +import { MagnifyingGlass } from "@phosphor-icons/react"; +import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; +import { SidebarItem } from "../SidebarItem"; +import { SidebarKbdHint } from "./SidebarKbdHint"; + +interface SearchItemProps { + onClick: () => void; +} + +export function SearchItem({ onClick }: SearchItemProps) { + return ( + } + label="Search" + onClick={onClick} + endContent={} + /> + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx b/apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx new file mode 100644 index 000000000..3a751d2ae --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx @@ -0,0 +1,21 @@ +import { Kbd } from "@posthog/quill"; +import { formatHotkey } from "@renderer/constants/keyboard-shortcuts"; + +interface SidebarKbdHintProps { + /** Raw shortcut string from SHORTCUTS, e.g. "mod+k". */ + keys: string; +} + +/** + * Keyboard shortcut hint for a sidebar nav item. Hidden until the parent + * SidebarItem (which carries the `group` class) is hovered. Toggled via + * `display` so it takes no space when idle and preceding `endContent` sits + * flush to the edge — no transition, to match the rest of the sidebar. + */ +export function SidebarKbdHint({ keys }: SidebarKbdHintProps) { + return ( + + {formatHotkey(keys)} + + ); +} diff --git a/apps/code/src/renderer/stores/commandMenuStore.ts b/apps/code/src/renderer/stores/commandMenuStore.ts index 8ce7bc971..1b3e9ed3b 100644 --- a/apps/code/src/renderer/stores/commandMenuStore.ts +++ b/apps/code/src/renderer/stores/commandMenuStore.ts @@ -1,17 +1,28 @@ import { create } from "zustand"; +export type CommandMenuScope = "commands" | "tasks"; + interface CommandMenuState { isOpen: boolean; - open: () => void; + scope: CommandMenuScope; + open: (scope?: CommandMenuScope) => void; close: () => void; toggle: () => void; setOpen: (open: boolean) => void; + setScope: (scope: CommandMenuScope) => void; } export const useCommandMenuStore = create((set) => ({ isOpen: false, - open: () => set({ isOpen: true }), + scope: "commands", + open: (scope = "commands") => set({ isOpen: true, scope }), close: () => set({ isOpen: false }), - toggle: () => set((state) => ({ isOpen: !state.isOpen })), + toggle: () => + set((state) => ({ + isOpen: !state.isOpen, + // Re-opening via the hotkey always lands on the commands scope. + scope: state.isOpen ? state.scope : "commands", + })), setOpen: (open) => set({ isOpen: open }), + setScope: (scope) => set({ scope }), })); diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 17f6e439b..7250605aa 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -39,7 +39,8 @@ export type CommandMenuAction = | "logout" | "toggle-theme" | "toggle-left-sidebar" - | "open-review-panel"; + | "open-review-panel" + | "open-task"; // Event property interfaces export interface TaskListViewProperties { From 57c0d4ac2e3d8a572003540336df0cbf2ca39566 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Mon, 18 May 2026 22:45:48 +0100 Subject: [PATCH 3/3] refactor(sidebar): unify task status icon across sidebar and palette - Extract TaskIcon (cloud/PR/branch/state logic) into a shared component; sidebar TaskItem and the command palette both render it - Command palette: one merged list (no scope Select / Tab cycling); commands + tasks together, task rows show full status icons - Icon colors set via the phosphor `color` prop (SVG fill) => survive quill's highlighted-row color reset - Task list items grow (min-height) so long task names wrap, not truncate - useTaskPrStatus accepts a minimal { id, cloudPrUrl } shape Co-Authored-By: Claude Opus 4.7 (1M context) --- .../command/components/CommandKeyHints.tsx | 6 - .../command/components/CommandMenu.tsx | 125 ++++------- .../sidebar/components/SidebarMenu.tsx | 2 +- .../sidebar/components/TaskListView.tsx | 2 +- .../sidebar/components/items/TaskIcon.tsx | 204 ++++++++++++++++++ .../sidebar/components/items/TaskItem.tsx | 171 ++------------- .../features/sidebar/hooks/useTaskPrStatus.ts | 4 +- .../src/renderer/stores/commandMenuStore.ts | 17 +- 8 files changed, 263 insertions(+), 268 deletions(-) create mode 100644 apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx diff --git a/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx b/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx index 8a74095b6..c06f67b35 100644 --- a/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx +++ b/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx @@ -16,12 +16,6 @@ export function CommandKeyHints() {
select
-
- - Tab - - switch scope -
Esc diff --git a/apps/code/src/renderer/features/command/components/CommandMenu.tsx b/apps/code/src/renderer/features/command/components/CommandMenu.tsx index 8d121d65c..8551cd305 100644 --- a/apps/code/src/renderer/features/command/components/CommandMenu.tsx +++ b/apps/code/src/renderer/features/command/components/CommandMenu.tsx @@ -2,6 +2,8 @@ import { useReviewNavigationStore } from "@features/code-review/stores/reviewNav import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; import { useFolders } from "@features/folders/hooks/useFolders"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { TaskIcon } from "@features/sidebar/components/items/TaskIcon"; +import { useTaskPrStatus } from "@features/sidebar/hooks/useTaskPrStatus"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { @@ -15,31 +17,21 @@ import { AutocompleteStatus, Dialog, DialogContent, - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, } from "@posthog/quill"; import { DesktopIcon, FileTextIcon, GearIcon, HomeIcon, - MagnifyingGlassIcon, MoonIcon, SunIcon, ViewVerticalIcon, } from "@radix-ui/react-icons"; +import type { Task } from "@shared/types"; import { ANALYTICS_EVENTS, type CommandMenuAction, } from "@shared/types/analytics"; -import { - type CommandMenuScope, - useCommandMenuStore, -} from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; import { useThemeStore } from "@stores/themeStore"; import { track } from "@utils/analytics"; @@ -61,47 +53,23 @@ type Command = { type CommandSection = { label: string; items: Command[] }; -const SCOPE_LABELS: Record = { - commands: "Commands", - tasks: "Tasks", -}; - -const SCOPE_PLACEHOLDERS: Record = { - commands: "Type a command…", - tasks: "Search tasks…", -}; - -/** Scope picker rendered inline at the end of the command input. */ -function ScopeSelect({ - scope, - onScopeChange, -}: { - scope: CommandMenuScope; - onScopeChange: (scope: CommandMenuScope) => void; -}) { +/** + * Task icon for the command palette. Renders the same shared `TaskIcon` as + * the sidebar — cloud run status, PR/branch status, etc. — deriving its + * inputs from the raw task and a per-task PR-status query. + */ +function TaskCommandIcon({ task }: { task: Task }) { + const { prState, hasDiff } = useTaskPrStatus({ + id: task.id, + cloudPrUrl: null, + }); return ( - + ); } @@ -119,8 +87,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const getReviewMode = useReviewNavigationStore( (state) => state.getReviewMode, ); - const scope = useCommandMenuStore((state) => state.scope); - const setScope = useCommandMenuStore((state) => state.setScope); const { data: tasks = [] } = useTasks(); const [query, setQuery] = useState(""); const [systemPrefersDark, setSystemPrefersDark] = useState( @@ -152,16 +118,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { } }, [open]); - // Clear the query whenever the scope flips so stale text doesn't carry over. - // biome-ignore lint/correctness/useExhaustiveDependencies: reset on scope change only - useEffect(() => { - setQuery(""); - }, [scope]); - - const cycleScope = useCallback(() => { - setScope(scope === "commands" ? "tasks" : "commands"); - }, [scope, setScope]); - const themeOptions = useMemo(() => { const options: Command[] = []; if (theme !== "light") { @@ -291,7 +247,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { items: tasks.map((task) => ({ id: `task-${task.id}`, label: task.title, - icon: , + icon: , action: "open-task" as CommandMenuAction, onRun: () => { closeSettingsDialog(); @@ -302,7 +258,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { ]; }, [tasks, navigateToTask, closeSettingsDialog]); - const sections = scope === "tasks" ? taskSections : commandSections; + // Commands and tasks share a single filterable list. + const sections = useMemo( + () => [...commandSections, ...taskSections], + [commandSections, taskSections], + ); const allCommands = useMemo( () => sections.flatMap((s) => s.items), @@ -344,33 +304,15 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }} > { - // Tab cycles between the Commands / Tasks scopes instead of - // moving focus out of the input. - if (event.key === "Tab") { - event.preventDefault(); - event.stopPropagation(); - cycleScope(); - } - }} - > - - + /> - - No tasks match "{query}" - - ) : ( - - No commands match "{query}" - - ) + + No results for "{query}" + } /> @@ -383,9 +325,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { key={cmd.id} value={cmd.id} onClick={() => handleSelect(cmd.id)} + // Long task names wrap instead of truncating, so the + // item must grow: min-height, not a fixed height. + className="h-auto! min-h-7 py-1.5 text-left" > {cmd.icon} - {cmd.label} + + {cmd.label} + )} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index b435bddb5..eb11dbdf4 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -149,7 +149,7 @@ function SidebarMenuComponent() { const openCommandMenu = useCommandMenuStore((s) => s.open); const handleSearchClick = () => { - openCommandMenu("tasks"); + openCommandMenu(); }; const handleTaskClick = (taskId: string) => { diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 0f804a053..b8cec1f3e 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -141,7 +141,7 @@ function TaskSearchButton() { type="button" aria-label="Search tasks" size="icon-sm" - onClick={() => openCommandMenu("tasks")} + onClick={() => openCommandMenu()} > diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx new file mode 100644 index 000000000..2d8ef6a02 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx @@ -0,0 +1,204 @@ +import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; +import { Tooltip } from "@components/ui/Tooltip"; +import type { SidebarPrState } from "@features/sidebar/hooks/useTaskPrStatus"; +import type { WorkspaceMode } from "@main/services/workspace/schemas"; +import { + ChatCircle, + Circle, + Cloud as CloudIcon, + GitBranch, + GitMerge, + GitPullRequest, + HandPalm, + Pause, + PushPin, +} from "@phosphor-icons/react"; +import { isTerminalStatus, type TaskRunStatus } from "@shared/types"; + +export const ICON_SIZE = 12; + +// Colors are passed as the phosphor `color` prop (an SVG `fill` attribute) +// rather than `text-*` classes: in the command palette, quill's +// `[data-highlighted] *` rule resets every descendant CSS `color` for the +// selected row, which turns a `currentColor` icon black on hover. An explicit +// `fill` is immune, and renders identically in the sidebar. + +function CloudStatusIcon({ taskRunStatus }: { taskRunStatus?: TaskRunStatus }) { + if (taskRunStatus === "queued" || taskRunStatus === "in_progress") { + return ( + + + + + + ); + } + if (taskRunStatus === "completed") { + return ( + + + + + + ); + } + if (taskRunStatus === "failed" || taskRunStatus === "cancelled") { + const label = + taskRunStatus === "cancelled" ? "Cloud (cancelled)" : "Cloud (failed)"; + return ( + + + + + + ); + } + return ( + + + + + + ); +} + +function PrStatusIcon({ + prState, + hasDiff, +}: { + prState?: SidebarPrState; + hasDiff?: boolean; +}) { + if (prState === "merged") { + return ( + + + + + + ); + } + if (prState === "open") { + return ( + + + + + + ); + } + if (prState === "draft") { + return ( + + + + + + ); + } + if (prState === "closed") { + return ( + + + + + + ); + } + if (hasDiff) { + return ( + + + + + + ); + } + return null; +} + +export interface TaskIconProps { + workspaceMode?: WorkspaceMode; + isGenerating?: boolean; + isUnread?: boolean; + isPinned?: boolean; + isSuspended?: boolean; + needsPermission?: boolean; + taskRunStatus?: TaskRunStatus; + prState?: SidebarPrState; + hasDiff?: boolean; +} + +/** + * Status icon for a task, shared by the sidebar task list and the command + * palette so both render the exact same states (cloud run status, PR/branch + * status, generating, unread, etc.). + */ +export function TaskIcon({ + workspaceMode, + isGenerating, + isUnread, + isPinned, + isSuspended, + needsPermission, + taskRunStatus, + prState, + hasDiff, +}: TaskIconProps) { + const isCloudTask = workspaceMode === "cloud"; + const isTerminalCloud = isCloudTask && isTerminalStatus(taskRunStatus); + + if (needsPermission) { + return ( + + + + + + ); + } + if (isTerminalCloud) { + return ; + } + if (isGenerating) { + return ; + } + if (isCloudTask) { + return ; + } + if (isSuspended) { + return ( + + + + + + ); + } + if (isUnread) { + return ( + + + + ); + } + if (prState || hasDiff) { + return ; + } + if (isPinned) { + return ; + } + return ; +} diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx index eb604baeb..16412e341 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -1,23 +1,12 @@ -import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; import { Tooltip } from "@components/ui/Tooltip"; import type { SidebarPrState } from "@features/sidebar/hooks/useTaskPrStatus"; import type { WorkspaceMode } from "@main/services/workspace/schemas"; -import { - Archive, - ChatCircle, - Circle, - Cloud as CloudIcon, - GitBranch, - GitMerge, - GitPullRequest, - HandPalm, - Pause, - PushPin, -} from "@phosphor-icons/react"; -import { isTerminalStatus, type TaskRunStatus } from "@shared/types"; +import { Archive, PushPin } from "@phosphor-icons/react"; +import type { TaskRunStatus } from "@shared/types"; import { formatRelativeTimeShort } from "@utils/time"; import { useCallback, useEffect, useRef, useState } from "react"; import { SidebarItem } from "../SidebarItem"; +import { TaskIcon } from "./TaskIcon"; interface TaskItemProps { depth?: number; @@ -110,119 +99,8 @@ function TaskHoverToolbar({ ); } -const ICON_SIZE = 12; const INDENT_SIZE = 8; -function CloudStatusIcon({ - taskRunStatus, -}: { - taskRunStatus?: TaskItemProps["taskRunStatus"]; -}) { - if (taskRunStatus === "queued" || taskRunStatus === "in_progress") { - return ( - - - - - - ); - } - if (taskRunStatus === "completed") { - return ( - - - - - - ); - } - if (taskRunStatus === "failed" || taskRunStatus === "cancelled") { - const label = - taskRunStatus === "cancelled" ? "Cloud (cancelled)" : "Cloud (failed)"; - return ( - - - - - - ); - } - return ( - - - - - - ); -} - -function PrStatusIcon({ - prState, - hasDiff, -}: { - prState?: SidebarPrState; - hasDiff?: boolean; -}) { - if (prState === "merged") { - return ( - - - - - - ); - } - if (prState === "open") { - return ( - - - - - - ); - } - if (prState === "draft") { - return ( - - - - - - ); - } - if (prState === "closed") { - return ( - - - - - - ); - } - if (hasDiff) { - return ( - - - - - - ); - } - return null; -} - export function TaskItem({ depth = 0, taskId, @@ -247,37 +125,18 @@ export function TaskItem({ onEditSubmit, onEditCancel, }: TaskItemProps) { - const isCloudTask = workspaceMode === "cloud"; - const isTerminalCloud = isCloudTask && isTerminalStatus(taskRunStatus); - - const icon = needsPermission ? ( - - - - - - ) : isTerminalCloud ? ( - - ) : isGenerating ? ( - - ) : isCloudTask ? ( - - ) : isSuspended ? ( - - - - - - ) : isUnread ? ( - - - - ) : prState || hasDiff ? ( - - ) : isPinned ? ( - - ) : ( - + const icon = ( + ); const timestampNode = timestamp ? ( diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts index 22c7787f3..cf034e96c 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -12,7 +12,9 @@ export interface TaskPrStatus { const SIDEBAR_STALE_TIME = 60_000; const EMPTY: TaskPrStatus = { prState: null, hasDiff: false }; -export function useTaskPrStatus(task: TaskData): TaskPrStatus { +export function useTaskPrStatus( + task: Pick, +): TaskPrStatus { const trpc = useTRPC(); const { data } = useQuery( diff --git a/apps/code/src/renderer/stores/commandMenuStore.ts b/apps/code/src/renderer/stores/commandMenuStore.ts index 1b3e9ed3b..8ce7bc971 100644 --- a/apps/code/src/renderer/stores/commandMenuStore.ts +++ b/apps/code/src/renderer/stores/commandMenuStore.ts @@ -1,28 +1,17 @@ import { create } from "zustand"; -export type CommandMenuScope = "commands" | "tasks"; - interface CommandMenuState { isOpen: boolean; - scope: CommandMenuScope; - open: (scope?: CommandMenuScope) => void; + open: () => void; close: () => void; toggle: () => void; setOpen: (open: boolean) => void; - setScope: (scope: CommandMenuScope) => void; } export const useCommandMenuStore = create((set) => ({ isOpen: false, - scope: "commands", - open: (scope = "commands") => set({ isOpen: true, scope }), + open: () => set({ isOpen: true }), close: () => set({ isOpen: false }), - toggle: () => - set((state) => ({ - isOpen: !state.isOpen, - // Re-opening via the hotkey always lands on the commands scope. - scope: state.isOpen ? state.scope : "commands", - })), + toggle: () => set((state) => ({ isOpen: !state.isOpen })), setOpen: (open) => set({ isOpen: open }), - setScope: (scope) => set({ scope }), }));