From a4b4200daf873e10bdbe974a9e5d25eb4180792f Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 2 Apr 2026 14:02:18 +0900 Subject: [PATCH 1/2] feat(report): add user instruction input and UI improvements - Add userInstruction textarea to ReportTab for customizing AI summary generation - Propagate userInstruction through API -> generator -> prompt builder pipeline - Add prompt injection isolation rules in system prompt for - Apply sanitizeMessage to userInstruction in buildSummaryPrompt (DR1-005) - Add body shape validation and userInstruction validation to daily-summary API - Change nav label from 'Review' to 'Review/Report' (Header + GlobalMobileNav) - Change Sessions default sort to repositoryName(asc) with branchName second sort - Add second sort key for status sort (repositoryName) - Add MAX_USER_INSTRUCTION_LENGTH=1000 constant to review-config - All 6037 unit tests pass, 0 ESLint errors, 0 TypeScript errors Resolves #612 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/daily-summary/route.ts | 30 +++++++- src/app/sessions/page.tsx | 10 ++- src/components/layout/Header.tsx | 2 +- src/components/mobile/GlobalMobileNav.tsx | 2 +- src/components/review/ReportTab.tsx | 23 +++++- src/config/review-config.ts | 6 ++ src/lib/daily-summary-generator.ts | 6 +- src/lib/summary-prompt-builder.ts | 14 +++- tests/unit/GlobalMobileNav.test.tsx | 10 +-- tests/unit/Header.test.tsx | 10 +-- .../unit/lib/daily-summary-generator.test.ts | 68 ++++++++++++++++- tests/unit/lib/summary-prompt-builder.test.ts | 73 +++++++++++++++++++ 12 files changed, 230 insertions(+), 24 deletions(-) diff --git a/src/app/api/daily-summary/route.ts b/src/app/api/daily-summary/route.ts index 1cb684a9..1f1df479 100644 --- a/src/app/api/daily-summary/route.ts +++ b/src/app/api/daily-summary/route.ts @@ -18,7 +18,7 @@ import { OutputValidationError, MAX_SUMMARY_OUTPUT_LENGTH, } from '@/lib/daily-summary-generator'; -import { SUMMARY_ALLOWED_TOOLS } from '@/config/review-config'; +import { SUMMARY_ALLOWED_TOOLS, MAX_USER_INSTRUCTION_LENGTH } from '@/config/review-config'; // ============================================================================= // Validation @@ -107,7 +107,13 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { const body = await request.json(); - const { date, tool, model } = body; + + // Body shape validation + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + const { date, tool, model, userInstruction } = body; // Validate date if (!date || typeof date !== 'string') { @@ -128,9 +134,27 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid model parameter' }, { status: 400 }); } + // Validate userInstruction (optional, Issue #612) + if (userInstruction !== undefined && userInstruction !== null) { + if (typeof userInstruction !== 'string') { + return NextResponse.json({ error: 'Invalid userInstruction parameter' }, { status: 400 }); + } + if (userInstruction.length > MAX_USER_INSTRUCTION_LENGTH) { + return NextResponse.json( + { error: `userInstruction exceeds maximum length (${MAX_USER_INSTRUCTION_LENGTH})` }, + { status: 400 } + ); + } + } + const db = getDbInstance(); - const report = await generateDailySummary(db, { date, tool, model }); + const report = await generateDailySummary(db, { + date, + tool, + model, + userInstruction: userInstruction || undefined, + }); return NextResponse.json({ report: { diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx index bf01a30c..9e99ec75 100644 --- a/src/app/sessions/page.tsx +++ b/src/app/sessions/page.tsx @@ -98,8 +98,8 @@ function formatStatus(status: string | null | undefined): string { export default function SessionsPage() { const { worktrees, isLoading, error } = useWorktreesCache(); const [filterText, setFilterText] = useState(''); - const [sortKey, setSortKey] = useState('lastSent'); - const [sortDirection, setSortDirection] = useState('desc'); + const [sortKey, setSortKey] = useState('repositoryName'); + const [sortDirection, setSortDirection] = useState('asc'); const filteredAndSorted = useMemo(() => { let result = worktrees; @@ -131,12 +131,18 @@ export default function SessionsPage() { } case 'repositoryName': { comparison = a.repositoryName.toLowerCase().localeCompare(b.repositoryName.toLowerCase()); + if (comparison === 0) { + comparison = a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + } break; } case 'status': { const priorityA = WORKTREE_STATUS_PRIORITY[a.status ?? ''] ?? DEFAULT_STATUS_PRIORITY; const priorityB = WORKTREE_STATUS_PRIORITY[b.status ?? ''] ?? DEFAULT_STATUS_PRIORITY; comparison = priorityA - priorityB; + if (comparison === 0) { + comparison = a.repositoryName.toLowerCase().localeCompare(b.repositoryName.toLowerCase()); + } break; } default: diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index cdfe79e5..14fb8c37 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -24,7 +24,7 @@ const NAV_ITEMS: Array<{ label: string; href: string; isActive: (pathname: strin { label: 'Home', href: '/', isActive: (p) => p === '/' }, { label: 'Sessions', href: '/sessions', isActive: (p) => p.startsWith('/sessions') }, { label: 'Repos', href: '/repositories', isActive: (p) => p.startsWith('/repositories') }, - { label: 'Review', href: '/review', isActive: (p) => p.startsWith('/review') }, + { label: 'Review/Report', href: '/review', isActive: (p) => p.startsWith('/review') }, { label: 'More', href: '/more', isActive: (p) => p.startsWith('/more') }, ]; diff --git a/src/components/mobile/GlobalMobileNav.tsx b/src/components/mobile/GlobalMobileNav.tsx index ab6fd8f3..00dbe6de 100644 --- a/src/components/mobile/GlobalMobileNav.tsx +++ b/src/components/mobile/GlobalMobileNav.tsx @@ -57,7 +57,7 @@ const MoreIcon = () => ( const MOBILE_NAV_TABS: MobileNavTab[] = [ { label: 'Home', href: '/', isActive: (p) => p === '/', icon: }, { label: 'Sessions', href: '/sessions', isActive: (p) => p.startsWith('/sessions'), icon: }, - { label: 'Review', href: '/review', isActive: (p) => p.startsWith('/review'), icon: }, + { label: 'Review/Report', href: '/review', isActive: (p) => p.startsWith('/review'), icon: }, { label: 'More', href: '/more', isActive: (p) => p.startsWith('/more'), icon: }, ]; diff --git a/src/components/review/ReportTab.tsx b/src/components/review/ReportTab.tsx index 9882b36e..4b9e8b58 100644 --- a/src/components/review/ReportTab.tsx +++ b/src/components/review/ReportTab.tsx @@ -9,7 +9,7 @@ import { useState, useEffect, useCallback } from 'react'; import ReportDatePicker from './ReportDatePicker'; -import { SUMMARY_ALLOWED_TOOLS } from '@/config/review-config'; +import { SUMMARY_ALLOWED_TOOLS, MAX_USER_INSTRUCTION_LENGTH } from '@/config/review-config'; /** Format Date to YYYY-MM-DD */ function formatToday(): string { @@ -41,6 +41,7 @@ export default function ReportTab() { const [isGenerating, setIsGenerating] = useState(false); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + const [userInstruction, setUserInstruction] = useState(''); // Fetch report for selected date const fetchReport = useCallback(async (date: string) => { @@ -90,6 +91,9 @@ export default function ReportTab() { if (selectedTool === 'copilot' && modelInput.trim()) { body.model = modelInput.trim(); } + if (userInstruction.trim()) { + body.userInstruction = userInstruction.trim(); + } const res = await fetch('/api/daily-summary', { method: 'POST', @@ -181,6 +185,23 @@ export default function ReportTab() { )} + + + {/* User instruction textarea (Issue #612) */} +
+