diff --git a/tests/unit/web/project-navigation.test.ts b/tests/unit/web/project-navigation.test.ts
index a39bcdaa..b54c1e46 100644
--- a/tests/unit/web/project-navigation.test.ts
+++ b/tests/unit/web/project-navigation.test.ts
@@ -12,11 +12,11 @@ describe('PROJECT_SECTIONS', () => {
expect(PROJECT_SECTIONS.map((s) => s.id)).toEqual([
'general',
'harness',
- 'work',
- 'stats',
'integrations',
'agent-configs',
'lifecycle',
+ 'work',
+ 'stats',
]);
});
diff --git a/tests/unit/web/utils.test.ts b/tests/unit/web/utils.test.ts
index 0756ce8b..079d58ef 100644
--- a/tests/unit/web/utils.test.ts
+++ b/tests/unit/web/utils.test.ts
@@ -3,7 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('clsx', () => ({ clsx: (...args: unknown[]) => args.join(' ') }));
vi.mock('tailwind-merge', () => ({ twMerge: (s: string) => s }));
-import { formatCost, formatDuration, formatRelativeTime } from '../../../web/src/lib/utils.js';
+import {
+ formatCost,
+ formatCostSummary,
+ formatDuration,
+ formatRelativeTime,
+} from '../../../web/src/lib/utils.js';
describe('formatDuration', () => {
it('returns "-" for null', () => {
@@ -60,6 +65,32 @@ describe('formatCost', () => {
});
});
+describe('formatCostSummary', () => {
+ it('returns "-" for null', () => {
+ expect(formatCostSummary(null)).toBe('-');
+ });
+
+ it('returns "-" for undefined', () => {
+ expect(formatCostSummary(undefined)).toBe('-');
+ });
+
+ it('formats number with 2 decimal places', () => {
+ expect(formatCostSummary(0.001)).toBe('$0.00');
+ expect(formatCostSummary(1.23456)).toBe('$1.23');
+ expect(formatCostSummary(0)).toBe('$0.00');
+ expect(formatCostSummary(5.5)).toBe('$5.50');
+ });
+
+ it('handles string input', () => {
+ expect(formatCostSummary('0.5')).toBe('$0.50');
+ expect(formatCostSummary('1.23456')).toBe('$1.23');
+ });
+
+ it('returns "-" for NaN string input', () => {
+ expect(formatCostSummary('not-a-number')).toBe('-');
+ });
+});
+
describe('formatRelativeTime', () => {
beforeEach(() => {
vi.useFakeTimers();
diff --git a/web/src/components/projects/project-general-form.tsx b/web/src/components/projects/project-general-form.tsx
index 5a27adfe..a794b7cd 100644
--- a/web/src/components/projects/project-general-form.tsx
+++ b/web/src/components/projects/project-general-form.tsx
@@ -1,22 +1,22 @@
import { ProjectSecretField } from '@/components/projects/project-secret-field.js';
import { useProjectUpdate } from '@/components/projects/use-project-update.js';
import { Badge } from '@/components/ui/badge.js';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { Input } from '@/components/ui/input.js';
import { Label } from '@/components/ui/label.js';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip.js';
import { trpc } from '@/lib/trpc.js';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
+import { HelpCircle } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
-function formatMs(ms: number): string {
- const minutes = ms / 1000 / 60;
- if (minutes % 60 === 0)
- return `${ms.toLocaleString()} (${minutes / 60} hour${minutes / 60 !== 1 ? 's' : ''})`;
- return `${ms.toLocaleString()} (${minutes} min)`;
-}
-
interface Project {
id: string;
name: string;
@@ -37,6 +37,19 @@ function numericFieldDefault(value: number | null | undefined): string {
return value != null ? String(value) : '';
}
+/** Convert watchdog ms → whole minutes for display */
+function msToMinutes(ms: number | null | undefined): string {
+ if (ms == null) return '';
+ return String(Math.round(ms / 60000));
+}
+
+/** Convert minutes string → ms for storage */
+function minutesToMs(minutes: string): number | null {
+ if (!minutes) return null;
+ const parsed = Number.parseInt(minutes, 10);
+ return Number.isNaN(parsed) ? null : parsed * 60000;
+}
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: five independent form sections (identity, budget, progress, watchdog, run links) with shared dirty-state tracking and reset logic
export function ProjectGeneralForm({ project }: { project: Project }) {
const updateMutation = useProjectUpdate(project.id);
@@ -50,9 +63,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
const defaults = defaultsQuery.data;
const [name, setName] = useState(project.name);
- const [watchdogTimeoutMs, setWatchdogTimeoutMs] = useState(
- numericFieldDefault(project.watchdogTimeoutMs),
- );
+ const [watchdogMinutes, setWatchdogMinutes] = useState(msToMinutes(project.watchdogTimeoutMs));
const [progressModel, setProgressModel] = useState(project.progressModel ?? '');
const [progressIntervalMinutes, setProgressIntervalMinutes] = useState(
project.progressIntervalMinutes ?? '',
@@ -67,7 +78,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
const isDirty = useMemo(() => {
return (
name !== project.name ||
- watchdogTimeoutMs !== numericFieldDefault(project.watchdogTimeoutMs) ||
+ watchdogMinutes !== msToMinutes(project.watchdogTimeoutMs) ||
progressModel !== (project.progressModel ?? '') ||
progressIntervalMinutes !== (project.progressIntervalMinutes ?? '') ||
workItemBudgetUsd !== (project.workItemBudgetUsd ?? '') ||
@@ -76,7 +87,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
);
}, [
name,
- watchdogTimeoutMs,
+ watchdogMinutes,
progressModel,
progressIntervalMinutes,
workItemBudgetUsd,
@@ -87,7 +98,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
function handleReset() {
setName(project.name);
- setWatchdogTimeoutMs(numericFieldDefault(project.watchdogTimeoutMs));
+ setWatchdogMinutes(msToMinutes(project.watchdogTimeoutMs));
setProgressModel(project.progressModel ?? '');
setProgressIntervalMinutes(project.progressIntervalMinutes ?? '');
setWorkItemBudgetUsd(project.workItemBudgetUsd ?? '');
@@ -100,7 +111,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
updateMutation.mutate(
{
name,
- watchdogTimeoutMs: watchdogTimeoutMs ? Number.parseInt(watchdogTimeoutMs, 10) : null,
+ watchdogTimeoutMs: minutesToMs(watchdogMinutes),
progressModel: progressModel || null,
progressIntervalMinutes: progressIntervalMinutes || null,
workItemBudgetUsd: workItemBudgetUsd || null,
@@ -125,14 +136,15 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
const budgetPlaceholder = defaults
? `${defaults.workItemBudgetUsd.toFixed(2)} (default)`
: 'e.g. 5.00';
- const budgetDescription = defaults ? `$${defaults.workItemBudgetUsd.toFixed(2)} USD` : '…';
- const watchdogPlaceholder = defaults ? formatMs(defaults.watchdogTimeoutMs) : 'e.g. 1800000';
- const watchdogDescription = defaults ? formatMs(defaults.watchdogTimeoutMs) : '…';
+ const watchdogDefaultMinutes = defaults ? Math.round(defaults.watchdogTimeoutMs / 60000) : null;
+ const watchdogPlaceholder =
+ watchdogDefaultMinutes != null ? `${watchdogDefaultMinutes} (default)` : 'e.g. 30';
+ const watchdogDescription =
+ watchdogDefaultMinutes != null ? `Default: ${watchdogDefaultMinutes} min` : '…';
const progressModelPlaceholder = defaults ? defaults.progressModel : 'e.g. gemini-flash';
const progressIntervalPlaceholder = defaults
? `${defaults.progressIntervalMinutes} (default)`
: 'e.g. 5';
- const progressIntervalDescription = defaults ? `${defaults.progressIntervalMinutes} min` : '…';
const progressModelDescription = defaults ? (
{defaults.progressModel}
) : (
@@ -140,219 +152,245 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
);
return (
-