Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/task-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Execution does not proceed until the plan is approved (unless plan is marked `NO

Plan approval can be done from:

- dashboard plan tab
- task dashboard plan section
- CLI via `parallax pending --approve <id>`

Rejection:
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const App = () => (
<Routes>
<Route path="/" element={<Index />} />
<Route path="/tasks/:taskId" element={<Index />} />
<Route path="/tasks/:taskId/:tab" element={<Index />} />
<Route path="/tasks/:taskId/logs" element={<Index />} />
<Route path="/settings" element={<Index />} />
<Route path="/settings/:projectIndex" element={<Index />} />
<Route path="*" element={<NotFound />} />
Expand Down
708 changes: 423 additions & 285 deletions packages/ui/src/components/LogViewer.tsx

Large diffs are not rendered by default.

54 changes: 53 additions & 1 deletion packages/ui/src/lib/task-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AppConfig, TaskPlanState } from '@parallax/common'
import { AGENT_PROVIDER, type AppConfig, type TaskPlanState } from '@parallax/common'
import { PLAN_EDITABLE_STATES, PROJECT_COLOR_PALETTE } from './task-constants'
import { PLAN_STATE, TASK_STATUS, type TaskStatus } from './task-constants'

Expand Down Expand Up @@ -30,6 +30,58 @@ export function resolveProjectProvider(config: AppConfig | null, projectId?: str
return project.pullFrom.provider
}

type TaskDisplayMetadata = {
provider: string
usedAi: string
model?: string
}

function resolveProject(config: AppConfig | null, projectId?: string) {
if (!config) {
throw new Error('Parallax config is not loaded.')
}

if (!projectId) {
throw new Error('Task is missing projectId.')
}

const project = config.projects.find((candidate) => candidate.id === projectId)
if (!project) {
throw new Error(`Project "${projectId}" is not present in config.`)
}

return project
}

function formatAgentLabel(agent: string | undefined) {
if (!agent) {
return 'Unknown'
}

if (agent === AGENT_PROVIDER.CLAUDE_CODE) {
return 'Claude Code'
}

return agent
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}

export function resolveTaskDisplayMetadata(
config: AppConfig | null,
projectId?: string,
lastAgent?: string
): TaskDisplayMetadata {
const project = resolveProject(config, projectId)

return {
provider: project.pullFrom.provider,
usedAi: formatAgentLabel(lastAgent ?? project.agent.provider),
model: project.agent.model || undefined,
}
}

export function planActionsState(planState?: TaskPlanState) {
if (!planState) {
return { canEdit: false, reason: 'Plan is not available for this task.' }
Expand Down
30 changes: 12 additions & 18 deletions packages/ui/src/pages/Index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'

const TASK_VIEW = 'tasks'
const SETTINGS_VIEW = 'settings'
const SUMMARY_TAB = 'summary'
const DASHBOARD_VIEW = 'dashboard'
const LOGS_VIEW = 'logs'

type ActiveView = typeof TASK_VIEW | typeof SETTINGS_VIEW
type ActiveTab = typeof SUMMARY_TAB | 'logs' | 'plan'
type TaskDetailView = typeof DASHBOARD_VIEW | typeof LOGS_VIEW

function resolveActiveView(pathname: string): ActiveView {
return pathname.startsWith('/settings') ? SETTINGS_VIEW : TASK_VIEW
}

function resolveActiveTab(tab: string | undefined): ActiveTab {
return tab === 'logs' || tab === 'plan' ? tab : SUMMARY_TAB
function resolveTaskDetailView(pathname: string): TaskDetailView {
return pathname.endsWith('/logs') ? LOGS_VIEW : DASHBOARD_VIEW
}

const Index = () => {
Expand All @@ -38,9 +39,8 @@ const Index = () => {
useParallax()
const navigate = useNavigate()
const location = useLocation()
const { taskId, tab, projectIndex } = useParams<{
const { taskId, projectIndex } = useParams<{
taskId?: string
tab?: string
projectIndex?: string
}>()

Expand All @@ -49,7 +49,7 @@ const Index = () => {
}

const activeView = resolveActiveView(location.pathname)
const activeTab = resolveActiveTab(tab)
const taskDetailView = resolveTaskDetailView(location.pathname)
const selectedTask = taskId ? tasks[taskId] ?? null : null
const selectedTaskId = taskId ?? null
const selectedSettingsId = projectIndex ? `project-${projectIndex}` : null
Expand All @@ -66,21 +66,13 @@ const Index = () => {
return
}

navigate(`/tasks/${id}/${SUMMARY_TAB}`)
navigate(`/tasks/${id}`)
}

const handleViewChange = (view: ActiveView) => {
navigate(view === SETTINGS_VIEW ? '/settings' : '/')
}

const handleTabChange = (nextTab: ActiveTab) => {
if (!taskId) {
return
}

navigate(`/tasks/${taskId}/${nextTab}`)
}

return (
<div className="flex h-screen overflow-hidden bg-[#060606]">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
Expand All @@ -99,13 +91,15 @@ const Index = () => {
planMarkdown={selectedTask.planMarkdown}
planPrompt={selectedTask.planPrompt}
planResult={selectedTask.planResult}
lastAgent={selectedTask.lastAgent}
config={config}
onRetry={retryTask}
onCancel={cancelTask}
onApprovePlan={approvePlan}
onRejectPlan={rejectPlan}
activeTab={activeTab}
onTabChange={handleTabChange}
viewMode={taskDetailView}
onOpenLogs={() => navigate(`/tasks/${selectedTask.id}/logs`)}
onOpenDashboard={() => navigate(`/tasks/${selectedTask.id}`)}
/>
) : activeView === SETTINGS_VIEW && projectIndex !== undefined ? (
<SettingsViewer projectIndex={Number.parseInt(projectIndex, 10)} config={config} />
Expand Down
127 changes: 127 additions & 0 deletions packages/ui/src/test/index-routing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
import { AGENT_PROVIDER, LOG_LEVEL, PULL_PROVIDER, TaskPlanState, TASK_REVIEW_STATE } from '@parallax/common'
import Index from '@/pages/Index'

vi.mock('@/hooks/useParallax', () => ({
useParallax: () => ({
tasks: {
'task-1': {
id: 'task-1',
externalId: 'REV-1',
title: 'Improve task dashboard',
description: 'Move the plan into the default view',
projectId: 'annu-dev',
msg: 'Plan ready. Awaiting approval.',
startTime: 1,
status: 'queued',
planState: TaskPlanState.PLAN_READY,
planMarkdown: '- Bring the plan into the dashboard',
planPrompt: undefined,
planResult: undefined,
lastAgent: AGENT_PROVIDER.CODEX,
executionAttempts: 0,
logs: [
{
message: '[task-1] Plan ready',
icon: 'ℹ',
level: 'info',
timestamp: 1,
kind: 'lifecycle',
source: 'system',
},
],
reviewState: TASK_REVIEW_STATE.NONE,
},
},
config: {
concurrency: 1,
logs: [LOG_LEVEL.INFO],
server: { apiPort: 3000, uiPort: 8080 },
projects: [
{
id: 'annu-dev',
workspaceDir: '/tmp/annu-dev',
pullFrom: { provider: PULL_PROVIDER.LINEAR, filters: {} },
agent: { provider: AGENT_PROVIDER.CODEX },
},
],
},
isConnected: true,
error: null,
orchestratorErrors: [],
retryTask: vi.fn(),
cancelTask: vi.fn(),
approvePlan: vi.fn(),
rejectPlan: vi.fn(),
}),
}))

vi.mock('@/components/EmptyState', () => ({
EmptyState: () => <div>Empty state</div>,
}))

function LocationProbe() {
const location = useLocation()
return <div data-testid="location">{location.pathname}</div>
}

function renderIndex(initialEntries: string[]) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route
path="/"
element={
<>
<Index />
<LocationProbe />
</>
}
/>
<Route
path="/tasks/:taskId"
element={
<>
<Index />
<LocationProbe />
</>
}
/>
<Route
path="/tasks/:taskId/logs"
element={
<>
<Index />
<LocationProbe />
</>
}
/>
</Routes>
</MemoryRouter>
)
}

describe('Index task routing', () => {
it('navigates to the dashboard route when selecting a task from the sidebar', () => {
renderIndex(['/'])

fireEvent.click(screen.getByRole('button', { name: /task-1/i }))

expect(screen.getByTestId('location').textContent).toBe('/tasks/task-1')
expect(screen.getByText('Editable execution plan')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Activity' })).toBeNull()
})

it('opens the dedicated logs route from the selected task dashboard', () => {
renderIndex(['/tasks/task-1'])

fireEvent.click(screen.getByRole('button', { name: 'Logs' }))

expect(screen.getByTestId('location').textContent).toBe('/tasks/task-1/logs')
expect(screen.getByPlaceholderText('Search transcript')).toBeTruthy()
expect(screen.getByRole('button', { name: 'All' })).toBeTruthy()
expect(screen.queryByText('Execution plan')).toBeNull()
})
})
Loading
Loading