Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion packages/apps/app/e2e/perf/scenarios.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ void __dirname
const ITERATIONS = 5
const RUN_DIR = path.join(defaultResultsDir(), `run-${Date.now()}`)
const PROJ_ABBREV = 'PE'
const PROJECT_NAME = 'PerfRun'

let projectId: string
let firstTaskId: string
Expand All @@ -46,7 +47,7 @@ test.describe
await resetApp(mainWindow)
const s = seed(mainWindow)
const p = await s.createProject({
name: 'PerfRun',
name: PROJECT_NAME,
color: '#8b5cf6',
path: TEST_PROJECT_PATH
})
Expand Down Expand Up @@ -168,6 +169,101 @@ test.describe
expect(result.runs.length).toBe(ITERATIONS)
})

test('open-closed-task-from-sidebar', async ({ mainWindow }) => {
const taskId = secondTaskId

const definition: ScenarioDefinition = {
name: 'open-closed-task-from-sidebar',
description: 'Click a closed task row in the sidebar tree, wait for task detail mount.',
iterations: ITERATIONS,
beforeEach: async (page) => {
await page.evaluate(
({ anchorId, targetId, pid }) => {
const storeApi = (window as any).__slayzone_tabStore
const store = storeApi.getState()
storeApi.setState({
sidebarView: 'projects',
selectedProjectId: pid,
treeStatusFilter: ['in_progress'],
treePriorityFilter: [1, 2, 3, 4, 5],
treeShowSubtasks: true,
treeShowAllSubtasks: false,
treeShowOnlyActive: false,
treeShowTemporary: true,
treeShowAllOpen: true,
treeCrossOutDone: false,
treeShowStatus: false,
treeShowPriority: true,
treeShowWorktree: true,
treeGroupBy: 'status',
treeOrderBy: 'manual',
treeOrderDir: 'asc'
})
store.openTaskInBackground(anchorId)
store.closeTabByTaskId(targetId)
store.setActiveTabIndex(0)
storeApi.setState({ sidebarView: 'tree' })
},
{ anchorId: firstTaskId, targetId: taskId, pid: projectId }
)
const expand = page.getByRole('button', { name: `Expand ${PROJECT_NAME}` }).first()
if (await expand.isVisible({ timeout: 500 }).catch(() => false)) {
await expand.click({ force: true }).catch(() => {})
}
await page.evaluate((id) => {
const store = (window as any).__slayzone_tabStore.getState()
performance.clearMarks(`sz:taskDetail:${id}:mount`)
store.closeTabByTaskId(id)
store.setActiveTabIndex(0)
}, taskId)
const row = page.locator(
`[data-sidebar-tree-item="task"][data-task-id="${taskId}"]`
)
await expect(row).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(150)
},
run: async (page) => {
await page.evaluate(async (id) => {
const mark = `sz:taskDetail:${id}:mount`
performance.clearMarks(mark)
const row = document.querySelector(
`[data-sidebar-tree-item="task"][data-task-id="${id}"]`
) as HTMLButtonElement | null
if (!row) throw new Error(`task row missing: ${id}`)
row.click()
await new Promise<void>((resolve, reject) => {
const deadline = performance.now() + 5000
const tick = () => {
if (performance.getEntriesByName(mark, 'mark').length > 0) {
resolve()
return
}
if (performance.now() > deadline) {
reject(new Error(`task detail mount timeout: ${id}`))
return
}
window.setTimeout(tick, 5)
}
tick()
})
}, taskId)
},
afterEach: async (page) => {
await page.evaluate((id) => {
const store = (window as any).__slayzone_tabStore.getState()
store.closeTabByTaskId(id)
store.setActiveTabIndex(0)
}, taskId)
await page.waitForTimeout(100)
}
}
const result = await profileScenario(mainWindow, definition, { runDir: RUN_DIR })
console.log(
`[perf] ${result.name} p50=${result.summary.wallP50}ms p95=${result.summary.wallP95}ms`
)
expect(result.runs.length).toBe(ITERATIONS)
})

test('create-task', async ({ mainWindow }) => {
// Make sure dialog is closed before starting
await mainWindow.evaluate(() =>
Expand Down
73 changes: 52 additions & 21 deletions packages/apps/app/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ import { BoostPill } from '@/components/usage/BoostPill'
import { useUsage } from '@/components/usage/useUsage'
import { useOnboardingChecklist } from '@/hooks/useOnboardingChecklist'
import { TaskShell } from '@slayzone/task/client/TaskShell'
import {
buildTaskDetailDataFromSnapshot,
taskDetailCache
} from '@slayzone/task/client/taskDetailCache'
// Extracted hooks (self-contained, clean interfaces)
import { useHomePanel, HOME_PANEL_SIZE_KEY } from '@/hooks/useHomePanel'
import { useTerminalStateTracking } from '@/hooks/useTerminalStateTracking'
Expand All @@ -159,11 +163,11 @@ import { useTabColors } from '@/hooks/useTabColors'
import { useVisibleTabs } from '@/hooks/useVisibleTabs'
import { useDiagnosticsSync } from '@/hooks/useDiagnosticsSync'
// Lazy-loaded: heavy components not needed for first paint
const TaskDetailDataLoader = lazy(() =>
const loadTaskDetailDataLoader = () =>
import('@slayzone/task/client/TaskDetailDataLoader').then((m) => ({
default: m.TaskDetailDataLoader
}))
)
const TaskDetailDataLoader = lazy(loadTaskDetailDataLoader)
const FileEditorView = lazy(() =>
import('@slayzone/file-editor/client/FileEditorView').then((m) => ({ default: m.FileEditorView }))
)
Expand Down Expand Up @@ -651,9 +655,34 @@ function App(): React.JSX.Element {
return m
}, [allIdleTasks])

const tasksMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks])
const projectsMap = useMemo(() => new Map(projects.map((p) => [p.id, p])), [projects])
const selectedProject = useMemo(
() => projects.find((p) => p.id === selectedProjectId) ?? null,
[projects, selectedProjectId]
() => projectsMap.get(selectedProjectId) ?? null,
[projectsMap, selectedProjectId]
)
const prefetchTaskDetail = useCallback(
(taskId: string) => {
const task = tasksMap.get(taskId)
if (task) {
taskDetailCache.prime(
'taskDetail',
[taskId],
buildTaskDetailDataFromSnapshot({
task,
tasks,
projects,
tags,
taskTagIds: taskTags.get(taskId) ?? [],
projectPathMissing: task.project_id === selectedProjectId ? projectPathMissing : false
})
Comment on lines +671 to +678

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Wrong path status This snapshot only carries the cached projectPathMissing value when the opened task belongs to the currently selected project. The sidebar can open tasks from other projects, so a task whose own project path is missing gets primed with projectPathMissing: false and the detail page renders repo-dependent UI before its async recheck corrects the state.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/apps/app/src/renderer/src/App.tsx
Line: 671-678

Comment:
**Wrong path status** This snapshot only carries the cached `projectPathMissing` value when the opened task belongs to the currently selected project. The sidebar can open tasks from other projects, so a task whose own project path is missing gets primed with `projectPathMissing: false` and the detail page renders repo-dependent UI before its async recheck corrects the state.

How can I resolve this? If you propose a fix, please make it concise.

)
} else {
taskDetailCache.prefetch('taskDetail', taskId)
}
void loadTaskDetailDataLoader()
},
[tasksMap, tasks, projects, tags, taskTags, selectedProjectId, projectPathMissing]
)

// Project lock guard — single chokepoint for task-open paths. Resolves the task's
Expand All @@ -672,8 +701,8 @@ function App(): React.JSX.Element {
const taskProject =
projectOverride ??
(() => {
const task = tasks.find((t) => t.id === taskId)
return task ? projects.find((p) => p.id === task.project_id) : undefined
const task = tasksMap.get(taskId)
return task ? projectsMap.get(task.project_id) : undefined
})()
if (taskProject && isProjectLocked(taskProject)) {
toast(PROJECT_LOCKED_TOAST)
Expand All @@ -684,9 +713,10 @@ function App(): React.JSX.Element {
return
}
if (taskProject) recordTaskOpen(taskProject.id)
prefetchTaskDetail(taskId)
fn(taskId)
},
[tasks, projects]
[tasksMap, projectsMap, prefetchTaskDetail]
)

const openTask = useCallback(
Expand Down Expand Up @@ -726,9 +756,6 @@ function App(): React.JSX.Element {
setSelectedProjectId(projects[0].id)
}, [projects, selectedProjectId, setSelectedProjectId])

// Task lookup map (used for tab props and active-tab project switching)
const tasksMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks])

// Visible tabs (project-scoped filtering — purely visual, full tabs stay mounted)
const { visibleTabs, visibleActiveIndex, toFullIndex, toVisibleIndex } = useVisibleTabs(
tabs,
Expand Down Expand Up @@ -870,14 +897,18 @@ function App(): React.JSX.Element {
}, [selectedProjectId, projects, validateProjectPath])

// Computed values
const projectTasks = selectedProjectId
? tasks.filter((t) => t.project_id === selectedProjectId)
: []
const projectTags = selectedProjectId
? tags.filter((t) => t.project_id === selectedProjectId)
: tags
const displayTasks = applyFilters(projectTasks, filter, taskTags, selectedProject?.columns_config)
const projectsMap = new Map(projects.map((p) => [p.id, p]))
const projectTasks = useMemo(
() => (selectedProjectId ? tasks.filter((t) => t.project_id === selectedProjectId) : []),
[tasks, selectedProjectId]
)
const projectTags = useMemo(
() => (selectedProjectId ? tags.filter((t) => t.project_id === selectedProjectId) : tags),
[tags, selectedProjectId]
)
const displayTasks = useMemo(
() => applyFilters(projectTasks, filter, taskTags, selectedProject?.columns_config),
[projectTasks, filter, taskTags, selectedProject?.columns_config]
)
const createTaskDialogDraft = useMemo(
() => ({ projectId: selectedProjectId || projects[0]?.id, ...createTaskDraft }),
[selectedProjectId, projects, createTaskDraft]
Expand Down Expand Up @@ -2201,6 +2232,7 @@ function App(): React.JSX.Element {
useTabStore.getState().setActiveView('usage-analytics')
}}
onTaskClick={openTask}
onTaskPrefetch={prefetchTaskDetail}
onCloseTab={closeTabByTaskId}
onOpenTaskInBackground={(id) => useTabStore.getState().openTaskInBackground(id)}
onCreateTemporaryTask={(projectId) => {
Expand Down Expand Up @@ -2257,9 +2289,7 @@ function App(): React.JSX.Element {
}
isPinned={!!task.pinned}
onTogglePin={() => setTaskPinned(task.id, !task.pinned)}
canMarkUnread={
terminalStates.get(task.id) === 'idle' && !task.needs_attention
}
canMarkUnread={terminalStates.get(task.id) === 'idle' && !task.needs_attention}
>
{child}
</TaskContextMenu>
Expand Down Expand Up @@ -2997,6 +3027,7 @@ function App(): React.JSX.Element {
onCreatedAndOpen={handleTaskCreatedAndOpen}
draft={createTaskDialogDraft}
tags={projectTags}
projects={projects}
onTagCreated={(tag: Tag) => setTags((prev) => [...prev, tag])}
/>
</Suspense>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface AppSidebarProps {
onUsageAnalytics: () => void
onLeaderboard: () => void
onTaskClick?: (taskId: string) => void
onTaskPrefetch?: (taskId: string) => void
onCloseTab?: (taskId: string) => void
onOpenTaskInBackground?: (taskId: string) => void
onCreateTemporaryTask?: (projectId: string) => void
Expand Down Expand Up @@ -132,6 +133,7 @@ export function AppSidebar({
onUsageAnalytics,
onLeaderboard,
onTaskClick,
onTaskPrefetch,
onCloseTab,
onOpenTaskInBackground,
onCreateTemporaryTask,
Expand Down Expand Up @@ -228,6 +230,7 @@ export function AppSidebar({
onSelectProject,
onProjectSettings,
onTaskClick,
onTaskPrefetch,
onCloseTab,
onOpenTaskInBackground,
onCreateTemporaryTask,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ interface TaskBranchCtx {
treeGroupBy: 'none' | 'status' | 'priority'
treeGroupPinned: boolean
onTaskClick?: (taskId: string) => void
onTaskPrefetch?: (taskId: string) => void
onRowSelectClick: (event: ReactMouseEvent<HTMLButtonElement>, taskId: string) => void
onCloseTab?: (taskId: string) => void
onOpenTaskInBackground?: (taskId: string) => void
Expand Down Expand Up @@ -327,6 +328,8 @@ function TaskRowView({
data-task-id={task.id}
data-active={isActive ? 'true' : undefined}
data-selected={isSelected ? 'true' : undefined}
onFocus={() => ctx.onTaskPrefetch?.(task.id)}
onPointerEnter={() => ctx.onTaskPrefetch?.(task.id)}
onClick={(e) => ctx.onRowSelectClick(e, task.id)}
onAuxClick={(e) => {
if (e.button !== 1) return
Expand All @@ -336,6 +339,7 @@ function TaskRowView({
else ctx.onOpenTaskInBackground?.(task.id)
}}
onMouseDown={(e) => {
if (e.button === 0) ctx.onTaskPrefetch?.(task.id)
if (e.button === 1) e.preventDefault()
}}
style={{ ...style, paddingLeft: tgPaddingLeft(depth), minHeight: TG_ROW_HEIGHT }}
Expand Down Expand Up @@ -853,6 +857,7 @@ export function TreeView({
onSelectProject,
onProjectSettings,
onTaskClick,
onTaskPrefetch,
onCloseTab,
onOpenTaskInBackground,
onCreateTemporaryTask,
Expand Down Expand Up @@ -1394,11 +1399,12 @@ export function TreeView({
}

// Plain click — clear selection, set anchor, open task.
onTaskPrefetch?.(taskId)
setSelectedTaskIds(new Set([taskId]))
setSelectionAnchorId(taskId)
onTaskClick?.(taskId)
},
[selectionAnchorId, tasksById, getSiblings, onTaskClick]
[selectionAnchorId, tasksById, getSiblings, onTaskPrefetch, onTaskClick]
)

// Render-order list of moved task ids — the dragged set, in tree visual
Expand Down Expand Up @@ -1918,6 +1924,7 @@ export function TreeView({
treeGroupBy,
treeGroupPinned,
onTaskClick,
onTaskPrefetch,
onRowSelectClick: handleRowSelectClick,
onCloseTab,
onOpenTaskInBackground,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface SidebarViewContext {
onSelectProject: (id: string) => void
onProjectSettings: (project: Project) => void
onTaskClick?: (taskId: string) => void
/** Warm task detail data/module before the user commits to opening the task. */
onTaskPrefetch?: (taskId: string) => void
/** Close a task tab by id (handles temporary task DB cleanup). */
onCloseTab?: (taskId: string) => void
/** Open a task as a background tab without changing focus. */
Expand Down
15 changes: 10 additions & 5 deletions packages/domains/projects/src/client/ProjectSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,31 @@ interface ProjectSelectProps {
value: string | undefined
onChange: (value: string) => void
disabled?: boolean
projects?: Project[]
}

export function ProjectSelect({
value,
onChange,
disabled
disabled,
projects
}: ProjectSelectProps): React.JSX.Element {
const [projects, setProjects] = useState<Project[]>([])
const [loadedProjects, setLoadedProjects] = useState<Project[]>([])

useEffect(() => {
window.api.db.getProjects().then(setProjects)
}, [])
if (projects) return
window.api.db.getProjects().then(setLoadedProjects)
}, [projects])

const options = projects ?? loadedProjects

return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent>
{[...projects]
{[...options]
.sort((a, b) => a.name.localeCompare(b.name))
.map((project) => (
<SelectItem key={project.id} value={project.id}>
Expand Down
Loading
Loading