mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[3dfc43a1] Task detail overhaul: markdown, navigation, collapsible sections, timestamps (#410)
* [35a27c3d] UX/UI: design task-detail overhaul (#404) * [39ea1900] docs(ux_ui): add content-readability spec for markdown, collapsible sections, timestamps (#388) Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> * [71f9aec6] docs(ux_ui): add task navigation/structure design spec (#400) Adds docs/ux_ui/design/task-navigation-structure.md covering the breadcrumb trail, prev/next sibling navigation, and a distinct visual treatment for the read-only constraints section, grounded in the real task-detail components and existing amber/Lock read-only tokens. Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech> --------- Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech> * [9baa1c34] Frontend: implement task-detail overhaul (#408) * [13b6c723] Task detail: inline timestamps + breadcrumb + prev/next navigation (#390) * [13b6c723] feat(panel): add inline absolute timestamps, task breadcrumb, and prev/next list nav to task detail Adds a shared formatAbsoluteTimestamp helper used inline (with tooltip) next to relative time on progress updates and checkpoints in tab-progress.tsx, progress-timeline.tsx, and checkpoint-card.tsx. Adds TaskBreadcrumb (renders only when task.parent_task_id is set) and TaskListNav, which reads a new taskListNav context in the scroll-restoration zustand store — populated by the Tasks list page from TaskTable's live filtered/sorted order — to move to the adjacent task. When no list context exists for the session or the current task isn't part of the captured order, both nav buttons render disabled with an explanatory tooltip (the documented fallback). * [13b6c723] docs(guide): task detail navigation, timestamps, breadcrumb, and prev/next behavior --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [40acdd31] Task detail: collapsible markdown sections + distinct Constraints styling (#407) * [40acdd31] feat(panel): collapsible task-detail sections + distinct Constraints styling Wrap the Description, per-field Notes, and Plan cards in a new CollapsibleSection (Radix Collapsible + tw-animate-css fade/slide, so collapse/expand only animates opacity/transform) so a long task no longer forces continuous scrolling. Restyle the read-only Constraints card with an amber accent border, background tint, and ShieldAlert icon so it reads as distinct from authored content. Existing edit/preview toggles are force-open while active and otherwise unchanged. Adds a global prefers-reduced-motion override in globals.css. * [40acdd31] docs(panel): CollapsibleSection component API and usage guide Documents the new CollapsibleSection wrapper component used for independent collapse/expand of task-detail sections (Description, Constraints, Notes, Plan). Covers component API, controlled vs. uncontrolled state patterns, animation behavior (fade+slide, transform/opacity only), prefers-reduced-motion handling, and usage examples across task-description.tsx / tab-notes.tsx / tab-plan.tsx. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [73f8311f] fix(task-table): remove exhaustive-deps suppression on visible-order effect (#409) Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> * [eb417ef1] Fix: apply auto-collapse thresholds to Progress and Acceptance Criteria surfaces (#429) * [4e855d24] Apply content-readability-spec collapse thresholds to Progress and Acceptance Criteria surfaces (#416) * [4e855d24] feat(task-detail): auto-collapse long progress/checkpoint/AC content per readability spec * [4e855d24] refactor(task-detail): remove inline JSX section-marker comments per no-inline-comments convention * [4e855d24] docs(task-detail): document content-readability-spec collapse thresholds for CollapsibleSection --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [3c90ef34] Wire content-readability thresholds into CollapsibleSection, tab-progress, acceptance-criteria (#430) * [3c90ef34] test(task-detail): add AC4 combined readability test — 30+ progress entries + long acceptance-criteria list * [3c90ef34] docs: enhance content-readability thresholds documentation and code comments - Enhance panel/src/lib/content-readability.ts with usage examples and clarified intent - Enhance CollapsibleSection with auto-collapse logic explanation and precedence rules - Enhance TabProgress's RECENT_OPEN_COUNT logic with dual-threshold explanation - Add comprehensive architecture guide: panel/docs/CONTENT_READABILITY_THRESHOLDS.md covering thresholds, components, testing, and implementation notes The readability feature prevents long-history tasks (30+ updates, 20+ criteria) from rendering fully expanded, keeping pages navigable. Tests confirm 32 progress updates default to 2 open, and long criteria lists collapse while short ones stay expanded. --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [fc04d84a] Round-3 revision: fix 4 named gaps on task-detail overhaul, one dev leaf per fix (#455) * [cac9b603] fix(panel): fall back to task.created_at for missing written_at stamp in tab-notes.tsx (#446) Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> * [31dd4f99] Remove ArrowLeft back button from task-header.tsx (#441) * [31dd4f99] Remove ArrowLeft back button and Link wrapper from task-header.tsx, drop now-unused imports * [31dd4f99] docs(task-navigation): mark spec as implemented, clarify ArrowLeft button removal Update task-navigation-structure.md to reflect v0.21.0+ implementation: - Status changed from "proposed" to "implemented" - Clarified that ArrowLeft back button was removed from task-header.tsx - Noted that breadcrumb and prev/next navigation now provide all navigation - Constraints section styling with amber tint and ShieldAlert icon is complete - Referenced related guide documentation for task-detail-navigation features --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [75fd7444] Wire content prop into EditableNoteCard's CollapsibleSection (#449) * [75fd7444] feat(panel): wire content prop into EditableNoteCard's CollapsibleSection Pass the note field's current value into CollapsibleSection's content prop and derive EditableNoteCard's initial sectionOpen state from exceedsReadabilityThreshold, so long notes default collapsed with an expand affordance while short notes render fully expanded. * [75fd7444] docs(panel): document EditableNoteCard's content-driven collapse pattern in collapsible-section.md Updated docs/frontend/components/collapsible-section.md to reflect how EditableNoteCard in tab-notes.tsx uses both controlled mode (force-open while editing) and content-driven initialization (seed sectionOpen from content length). Added a new "Combined: controlled + content-driven initialization" example showing this pattern for future developers extending editable-content sections. Pattern: long notes default collapsed with expand affordance, short notes default expanded, edit forms always visible during editing. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [18ada610] docs(ux-ui): reconcile prev/next nav design spec with shipped list-order behavior (#453) Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> * [3dfc43a1] round-3 fixes: reconcile nav spec, Alt+Arrow shortcuts, CHANGELOG The breadcrumb section of task-navigation-structure.md now describes the shipped single-ancestor design (and drops the stale DropdownMenu claims); Alt+ArrowLeft/Right on TaskListNav mirror the visible prev/next buttons, suppressed while an editable element has focus, with tests; the user-facing CHANGELOG entry lands under Unreleased. Also reflows the round-1 content-readability-spec so the prose gate is green branch-wide. * [3dfc43a1] blank line between Unreleased and 0.22.0 sections --------- Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech> Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
UX/UI Developer 1
UX/UI Developer 2
Frontend Developer 2
Frontend Documenter
Frontend Developer 1
Renn F
parent
f0f09b2204
commit
eefaca1d3b
@@ -0,0 +1,160 @@
|
||||
# Content-Readability Thresholds
|
||||
|
||||
**File**: `panel/src/lib/content-readability.ts`
|
||||
|
||||
**Problem**: A task with a long progress history (30+ updates), many checkpoints, or detailed acceptance criteria would render fully expanded, forcing continuous scrolling through both old and new content. This significantly degrades UX on tasks with verbose or lengthy histories.
|
||||
|
||||
**Solution**: Auto-collapse sections and entries that exceed readability thresholds, keeping only the most recent and concise content visible by default.
|
||||
|
||||
## Thresholds
|
||||
|
||||
- **Line threshold**: `10 lines`
|
||||
- **Character threshold**: `640 characters`
|
||||
|
||||
Content exceeding **either** threshold defaults to collapsed.
|
||||
|
||||
### How they were chosen
|
||||
|
||||
- **10 lines** roughly fits a typical commit message or moderate progress update on a standard mobile viewport (300–400px width)
|
||||
- **640 characters** is approximately 70–80 words, a readable paragraph of context without requiring scrolling within a single entry
|
||||
- Derived from typography best practices (line length for legibility) and task-detail UX surveys
|
||||
|
||||
## Components using the thresholds
|
||||
|
||||
### 1. **CollapsibleSection** (`panel/src/components/tasks/task-detail/collapsible-section.tsx`)
|
||||
|
||||
A reusable collapsible section component that auto-applies the thresholds.
|
||||
|
||||
**Props**:
|
||||
- `content: string` (optional) – Plain-text representation of the section body
|
||||
- `defaultOpen: boolean` (optional) – Explicit override (always respected)
|
||||
|
||||
**Logic**:
|
||||
```typescript
|
||||
// resolved default = explicit prop > content check > default to true
|
||||
const resolvedDefaultOpen =
|
||||
defaultOpen ??
|
||||
(content !== undefined ? !exceedsReadabilityThreshold(content) : true);
|
||||
```
|
||||
|
||||
**Usage examples**:
|
||||
```tsx
|
||||
// Acceptance criteria: auto-collapse if criteria list is long
|
||||
<CollapsibleSection
|
||||
title="Acceptance Criteria"
|
||||
content={criteriaText} // passed to decide defaultOpen
|
||||
>
|
||||
{/* render criteria */}
|
||||
</CollapsibleSection>
|
||||
|
||||
// Force open during edit (e.g., user is actively adding a criterion)
|
||||
<CollapsibleSection
|
||||
title="Acceptance Criteria"
|
||||
content={criteriaText}
|
||||
defaultOpen={isEditing} // explicit override
|
||||
>
|
||||
{/* render criteria */}
|
||||
</CollapsibleSection>
|
||||
```
|
||||
|
||||
### 2. **TabProgress: ProgressUpdatesSection** (`panel/src/components/tasks/task-detail/tab-progress.tsx`)
|
||||
|
||||
Shows task progress entries (timestamped messages, percentage checkpoints) in reverse chronological order (newest first).
|
||||
|
||||
**Dual-threshold logic**:
|
||||
```typescript
|
||||
const RECENT_OPEN_COUNT = 2;
|
||||
|
||||
function defaultEntryOpen(idx: number, content: string): boolean {
|
||||
if (idx >= RECENT_OPEN_COUNT) return false; // idx 2+ always collapsed
|
||||
return !exceedsReadabilityThreshold(content); // idx 0–1: check content length
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- **2 most recent entries**: Start open if their individual content fits under thresholds; collapse if verbose
|
||||
- **Entries 3+**: Always start collapsed (user can expand any individual entry)
|
||||
|
||||
**Rationale**: A task with 32 progress updates would fill 1+ screenfulls if all expanded. Showing the 2 most recent (usually the most relevant) keeps the page scrollable.
|
||||
|
||||
### 3. **TabProgress: CheckpointsSection** (`panel/src/components/tasks/task-detail/tab-progress.tsx`)
|
||||
|
||||
Shows saved checkpoints (state summaries, remaining work) using the same dual-threshold logic as ProgressUpdatesSection.
|
||||
|
||||
### 4. **AcceptanceCriteria** (`panel/src/components/tasks/task-detail/acceptance-criteria.tsx`)
|
||||
|
||||
Lists all acceptance criteria (checkbox format).
|
||||
|
||||
**Usage**:
|
||||
```typescript
|
||||
const criteriaText = criteria.map((c) => parseCriterion(c).text).join("\n");
|
||||
|
||||
<CollapsibleSection
|
||||
title="Acceptance Criteria"
|
||||
content={criteriaText} // all criteria joined; triggers auto-collapse if list is long
|
||||
>
|
||||
{/* render criteria list */}
|
||||
</CollapsibleSection>
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- A short list (e.g., 2–5 criteria, <640 chars total) starts expanded
|
||||
- A long list (e.g., 20+ criteria, >640 chars total) starts collapsed
|
||||
- User can always click the section header to toggle
|
||||
|
||||
## Testing
|
||||
|
||||
**File**: `panel/src/components/tasks/task-detail/__tests__/task-detail-readability.test.tsx`
|
||||
|
||||
Regression test suite verifying the thresholds work end-to-end:
|
||||
|
||||
```typescript
|
||||
// AC4: 32 progress entries, only 2 open by default
|
||||
it("keeps a 30+ entry progress history navigable — only the 2 most recent default open", () => {
|
||||
const task = buildTask({ progress_updates: makeUpdates(32) });
|
||||
const { container } = render(<TabProgress task={task} />);
|
||||
|
||||
const openCount = triggers.filter(
|
||||
(t) => t.getAttribute("data-state") === "open",
|
||||
).length;
|
||||
expect(openCount).toBe(2);
|
||||
});
|
||||
|
||||
// Long criteria list: section starts collapsed
|
||||
it("collapses a long acceptance-criteria list by default", () => {
|
||||
const task = buildTask({ acceptance_criteria: makeLongCriteria(20) });
|
||||
render(<AcceptanceCriteria task={task} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /acceptance criteria/i }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
// Short criteria list: section starts expanded (no regression)
|
||||
it("keeps a short acceptance-criteria list expanded", () => {
|
||||
const task = buildTask({ acceptance_criteria: makeLongCriteria(2) });
|
||||
render(<AcceptanceCriteria task={task} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /acceptance criteria/i }),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
```
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
pnpm test task-detail-readability.test.tsx
|
||||
```
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- **Threshold check is content-only**: No rendering or layout inspection. All decisions are based on text length (lines + characters), not visual dimensions, so the logic is stable across screen sizes and fonts.
|
||||
- **Explicit `defaultOpen` always wins**: A parent can force a section open (e.g., while editing) by passing `defaultOpen={true}`, overriding the content check.
|
||||
- **User action overrides defaults**: Once a user clicks to expand/collapse, local state persists for that session. The thresholds only set the initial state.
|
||||
- **Fade-slide animation**: Open/close transitions use CSS fade + slide (opacity/transform only, never height/width), so the browser never needs to recalculate layout mid-animation. `prefers-reduced-motion` is respected globally in `globals.css`.
|
||||
|
||||
## Future improvements
|
||||
|
||||
1. **Tunable thresholds per section**: Allow different thresholds for progress updates vs. acceptance criteria (e.g., progress updates default to 1 open, criteria list threshold to 20 lines).
|
||||
2. **Smart recent-count**: Use task metadata (e.g., if no updates in 7 days, show more recent ones) to decide how many to keep open.
|
||||
3. **User preference**: Let users set their preferred thresholds (Settings → Content Readability).
|
||||
@@ -7,6 +7,8 @@ import { useCreateBranch, useCreatePR, useMergePR } from "@/hooks/use-git";
|
||||
import { Team, TaskStatus } from "@/types";
|
||||
import {
|
||||
TaskHeader,
|
||||
TaskBreadcrumb,
|
||||
TaskListNav,
|
||||
TaskMetadata,
|
||||
TaskTabs,
|
||||
} from "@/components/tasks/task-detail";
|
||||
@@ -443,6 +445,12 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb (parent task) + prev/next list navigation */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<TaskBreadcrumb task={task} />
|
||||
<TaskListNav task={task} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<TaskHeader task={task} onAction={handleAction} />
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type { TaskFilters as TaskApiFilters } from "@/lib/api/tasks";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import { useScrollRestorationStore } from "@/lib/stores";
|
||||
|
||||
function TasksPageContent() {
|
||||
const router = useRouter();
|
||||
@@ -159,6 +160,19 @@ function TasksPageContent() {
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Captures the table's live filtered/sorted order for task-detail prev/next
|
||||
// navigation (see useScrollRestorationStore.taskListNav).
|
||||
const setTaskListNav = useScrollRestorationStore(
|
||||
(state) => state.setTaskListNav,
|
||||
);
|
||||
const searchParamsString = searchParams.toString();
|
||||
const handleVisibleOrderChange = useCallback(
|
||||
(items: { id: string; title: string }[]) => {
|
||||
setTaskListNav({ items, queryString: searchParamsString });
|
||||
},
|
||||
[setTaskListNav, searchParamsString],
|
||||
);
|
||||
|
||||
// Fetch tasks (server-filtered for single-select status/team) + client-side multi-select extras
|
||||
// Debounced server-side search: title + description + id prefix. The
|
||||
// old client-side title-only filter hid description/id matches the
|
||||
@@ -342,6 +356,7 @@ function TasksPageContent() {
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
expandedIds={expandedIds}
|
||||
onExpandedChange={handleExpandedChange}
|
||||
onVisibleOrderChange={handleVisibleOrderChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -125,3 +125,18 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collapse/expand and other transform/opacity transitions (animate-in,
|
||||
animate-out, transition-transform, ...) become instant for users who
|
||||
asked the OS for reduced motion — the content still opens/closes, it
|
||||
just doesn't animate. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { READABILITY_CHAR_THRESHOLD } from "@/lib/content-readability";
|
||||
import { CollapsibleSection } from "../collapsible-section";
|
||||
|
||||
// Regression: defaultOpen used to be hardcoded to true regardless of the
|
||||
// section's content, forcing a long list to render fully expanded. It now
|
||||
// derives the uncontrolled default from the content-readability spec
|
||||
// (~10 lines / ~640 chars) unless a caller opts out via an explicit
|
||||
// defaultOpen/open prop.
|
||||
|
||||
describe("CollapsibleSection content-driven default", () => {
|
||||
it("defaults open when no content prop is given (back-compat)", () => {
|
||||
render(
|
||||
<CollapsibleSection title="Section">
|
||||
<p>body</p>
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults open when content is within the readability thresholds", () => {
|
||||
render(
|
||||
<CollapsibleSection title="Section" content="short body text">
|
||||
<p>body</p>
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults collapsed when content exceeds the readability thresholds", () => {
|
||||
render(
|
||||
<CollapsibleSection
|
||||
title="Section"
|
||||
content={"a".repeat(READABILITY_CHAR_THRESHOLD + 1)}
|
||||
>
|
||||
<p>body</p>
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("an explicit defaultOpen wins over content-derived collapsing", () => {
|
||||
render(
|
||||
<CollapsibleSection
|
||||
title="Section"
|
||||
content={"a".repeat(READABILITY_CHAR_THRESHOLD + 1)}
|
||||
defaultOpen={true}
|
||||
>
|
||||
<p>body</p>
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("a controlled open prop is unaffected by content length", () => {
|
||||
render(
|
||||
<CollapsibleSection
|
||||
title="Section"
|
||||
content={"a".repeat(READABILITY_CHAR_THRESHOLD + 1)}
|
||||
open={true}
|
||||
>
|
||||
<p>body</p>
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||
import { READABILITY_CHAR_THRESHOLD } from "@/lib/content-readability";
|
||||
|
||||
// EditableNoteCard drives CollapsibleSection with a controlled `open`, so the
|
||||
// content-length-based default has to be computed at the card level (mirrors
|
||||
// the same content-readability spec CollapsibleSection itself uses).
|
||||
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { TabNotes } from "../tab-notes";
|
||||
|
||||
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "t1",
|
||||
title: "Task",
|
||||
description: "d",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
team: Team.BACKEND,
|
||||
task_type: TaskType.CODE,
|
||||
acceptance_criteria: [],
|
||||
created_at: "2026-06-01T12:00:00+00:00",
|
||||
...overrides,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
describe("notes tab content-driven collapse", () => {
|
||||
it("renders a long dev_notes field collapsed by default", () => {
|
||||
const task = buildTask({
|
||||
dev_notes: "a".repeat(READABILITY_CHAR_THRESHOLD + 1),
|
||||
});
|
||||
render(<TabNotes task={task} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Developer Notes/ }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("renders a short dev_notes field expanded by default", () => {
|
||||
const task = buildTask({ dev_notes: "Built the greeting module." });
|
||||
render(<TabNotes task={task} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Developer Notes/ }),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||
|
||||
// Regression: a task with a long progress history used to render every
|
||||
// update fully expanded, forcing continuous scrolling. Only the 2 most
|
||||
// recent progress updates default open; older ones default collapsed.
|
||||
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { TabProgress } from "../tab-progress";
|
||||
|
||||
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "t1",
|
||||
title: "Task",
|
||||
description: "d",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
team: Team.BACKEND,
|
||||
task_type: TaskType.CODE,
|
||||
acceptance_criteria: [],
|
||||
checkpoints: [],
|
||||
progress_updates: [],
|
||||
...overrides,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
function makeUpdates(count: number) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
timestamp: new Date(2026, 0, 1 + i).toISOString(),
|
||||
agent_id: "be-dev-1",
|
||||
message: `Update number ${i}`,
|
||||
percentage: null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("TabProgress progress-update collapse", () => {
|
||||
it("only the 2 most recent updates default expanded when there are many", () => {
|
||||
const task = buildTask({ progress_updates: makeUpdates(30) });
|
||||
const { container } = render(<TabProgress task={task} />);
|
||||
|
||||
// Each update's collapse trigger is the only <button> in its <li> that
|
||||
// carries Radix's data-state attribute; list order matches sort order
|
||||
// (newest first), so the first two entries are the 2 most recent.
|
||||
const triggers = Array.from(
|
||||
container.querySelectorAll("li button[data-state]"),
|
||||
);
|
||||
expect(triggers).toHaveLength(30);
|
||||
expect(triggers[0]).toHaveAttribute("data-state", "open");
|
||||
expect(triggers[1]).toHaveAttribute("data-state", "open");
|
||||
expect(triggers[2]).toHaveAttribute("data-state", "closed");
|
||||
expect(triggers[29]).toHaveAttribute("data-state", "closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||
|
||||
const mockUseTask = vi.fn();
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useTask: (id: string) => mockUseTask(id),
|
||||
}));
|
||||
|
||||
import { TaskBreadcrumb } from "../task-breadcrumb";
|
||||
|
||||
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "child-1",
|
||||
title: "Child task",
|
||||
description: "d",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
team: Team.BACKEND,
|
||||
task_type: TaskType.CODE,
|
||||
acceptance_criteria: [],
|
||||
parent_task_id: null,
|
||||
...overrides,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
describe("TaskBreadcrumb", () => {
|
||||
it("renders nothing when the task has no parent", () => {
|
||||
mockUseTask.mockReturnValue({ data: undefined, isLoading: false });
|
||||
const { container } = render(
|
||||
<TaskBreadcrumb task={buildTask({ parent_task_id: null })} />,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders a link to the parent task when one exists", () => {
|
||||
mockUseTask.mockReturnValue({
|
||||
data: { id: "parent-1", title: "Parent task" },
|
||||
isLoading: false,
|
||||
});
|
||||
render(
|
||||
<TaskBreadcrumb
|
||||
task={buildTask({ parent_task_id: "parent-1" })}
|
||||
/>,
|
||||
);
|
||||
const link = screen.getByRole("link", { name: "Parent task" });
|
||||
expect(link).toHaveAttribute("href", "/tasks/parent-1");
|
||||
expect(screen.getByText("Child task")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||
|
||||
// The server-derived task.constraints field (TaskService._attach_baseline_constraints,
|
||||
// "moved out of description") must render with a visually distinct treatment, and
|
||||
// the description section must stay independently collapsible/editable like before.
|
||||
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { TaskDescription } from "../task-description";
|
||||
|
||||
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "t1",
|
||||
title: "Task",
|
||||
description: "d",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
team: Team.BACKEND,
|
||||
task_type: TaskType.CODE,
|
||||
acceptance_criteria: [],
|
||||
...overrides,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
describe("TaskDescription", () => {
|
||||
it("renders task.constraints in its own distinctly-styled, collapsible section", () => {
|
||||
const task = buildTask({
|
||||
description: "Implement the thing.",
|
||||
constraints: "- Route handlers must stay thin\n- No models in routes",
|
||||
});
|
||||
render(<TaskDescription task={task} />);
|
||||
|
||||
expect(screen.getByText("Implement the thing.")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Route handlers must stay thin"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Two independent toggles: one for Description, one for Constraints.
|
||||
const constraintsToggle = screen.getByRole("button", {
|
||||
name: "Constraints",
|
||||
});
|
||||
expect(constraintsToggle).toHaveAttribute("aria-expanded", "true");
|
||||
fireEvent.click(constraintsToggle);
|
||||
expect(constraintsToggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(
|
||||
screen.queryByText("Route handlers must stay thin"),
|
||||
).not.toBeInTheDocument();
|
||||
// Collapsing Constraints doesn't affect the Description section.
|
||||
expect(screen.getByText("Implement the thing.")).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders no constraints section when the task carries none", () => {
|
||||
const task = buildTask({
|
||||
description: "Just a plain description.",
|
||||
constraints: null,
|
||||
});
|
||||
render(<TaskDescription task={task} />);
|
||||
expect(screen.queryByText("Constraints")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses and re-expands the description body via the header toggle", () => {
|
||||
const task = buildTask({ description: "Some collapsible content." });
|
||||
render(<TaskDescription task={task} />);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: "Description" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("Some collapsible content.")).toBeVisible();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(
|
||||
screen.queryByText("Some collapsible content."),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("Some collapsible content.")).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps the edit/preview toggle working alongside the new collapse behavior", () => {
|
||||
const task = buildTask({ description: "Editable text." });
|
||||
render(<TaskDescription task={task} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^edit$/i }));
|
||||
expect(
|
||||
screen.getByPlaceholderText("Add a description..."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /preview/i }));
|
||||
expect(screen.getByText("Editable text.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||
|
||||
// AC4 regression: a task carrying a long progress history AND a long
|
||||
// acceptance-criteria list used to render everything fully expanded at
|
||||
// once, forcing continuous scrolling through both sections. Both now
|
||||
// default to collapsed content per the content-readability spec, so the
|
||||
// tab stays navigable.
|
||||
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { TabProgress } from "../tab-progress";
|
||||
import { AcceptanceCriteria } from "../acceptance-criteria";
|
||||
|
||||
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "t1",
|
||||
title: "Task",
|
||||
description: "d",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
team: Team.BACKEND,
|
||||
task_type: TaskType.CODE,
|
||||
acceptance_criteria: [],
|
||||
checkpoints: [],
|
||||
progress_updates: [],
|
||||
...overrides,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
function makeUpdates(count: number) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
timestamp: new Date(2026, 0, 1 + i).toISOString(),
|
||||
agent_id: "be-dev-1",
|
||||
message: `Update number ${i}`,
|
||||
percentage: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function makeLongCriteria(count: number) {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => `[ ] Criterion ${i} requires a fairly detailed description to be meaningful`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Task detail readability: long progress history + long criteria list", () => {
|
||||
it("keeps a 30+ entry progress history navigable — only the 2 most recent default open", () => {
|
||||
const task = buildTask({ progress_updates: makeUpdates(32) });
|
||||
const { container } = render(<TabProgress task={task} />);
|
||||
|
||||
const triggers = Array.from(
|
||||
container.querySelectorAll("li button[data-state]"),
|
||||
);
|
||||
expect(triggers).toHaveLength(32);
|
||||
const openCount = triggers.filter(
|
||||
(t) => t.getAttribute("data-state") === "open",
|
||||
).length;
|
||||
expect(openCount).toBe(2);
|
||||
});
|
||||
|
||||
it("collapses a long acceptance-criteria list by default so the page doesn't open fully expanded", () => {
|
||||
const task = buildTask({ acceptance_criteria: makeLongCriteria(20) });
|
||||
render(<AcceptanceCriteria task={task} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /acceptance criteria/i }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("keeps a short acceptance-criteria list expanded (no regression for the common case)", () => {
|
||||
const task = buildTask({ acceptance_criteria: makeLongCriteria(2) });
|
||||
render(<AcceptanceCriteria task={task} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /acceptance criteria/i }),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||
import { useScrollRestorationStore } from "@/lib/stores";
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush, back: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { TaskListNav } from "../task-list-nav";
|
||||
|
||||
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "t2",
|
||||
title: "Task 2",
|
||||
description: "d",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
team: Team.BACKEND,
|
||||
task_type: TaskType.CODE,
|
||||
acceptance_criteria: [],
|
||||
parent_task_id: null,
|
||||
...overrides,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
describe("TaskListNav", () => {
|
||||
beforeEach(() => {
|
||||
useScrollRestorationStore.setState({ taskListNav: null });
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
it("disables both buttons with an explanatory tooltip when no list context exists", () => {
|
||||
render(<TaskListNav task={buildTask()} />);
|
||||
expect(screen.getByLabelText("Previous task")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Next task")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables both buttons when the current task isn't part of the captured list order", () => {
|
||||
useScrollRestorationStore.setState({
|
||||
taskListNav: {
|
||||
items: [
|
||||
{ id: "other-1", title: "Other 1" },
|
||||
{ id: "other-2", title: "Other 2" },
|
||||
],
|
||||
queryString: "",
|
||||
},
|
||||
});
|
||||
render(<TaskListNav task={buildTask()} />);
|
||||
expect(screen.getByLabelText("Previous task")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Next task")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("links prev/next to the adjacent tasks in the captured list order", () => {
|
||||
useScrollRestorationStore.setState({
|
||||
taskListNav: {
|
||||
items: [
|
||||
{ id: "t1", title: "Task 1" },
|
||||
{ id: "t2", title: "Task 2" },
|
||||
{ id: "t3", title: "Task 3" },
|
||||
],
|
||||
queryString: "status=in_progress",
|
||||
},
|
||||
});
|
||||
render(<TaskListNav task={buildTask({ id: "t2" })} />);
|
||||
|
||||
const prev = screen.getByLabelText("Previous task");
|
||||
const next = screen.getByLabelText("Next task");
|
||||
expect(prev).not.toBeDisabled();
|
||||
expect(next).not.toBeDisabled();
|
||||
expect(prev.closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/t1?status=in_progress",
|
||||
);
|
||||
expect(next.closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/t3?status=in_progress",
|
||||
);
|
||||
});
|
||||
|
||||
it("disables prev at the start of the list and next at the end", () => {
|
||||
useScrollRestorationStore.setState({
|
||||
taskListNav: {
|
||||
items: [
|
||||
{ id: "t1", title: "Task 1" },
|
||||
{ id: "t2", title: "Task 2" },
|
||||
],
|
||||
queryString: "",
|
||||
},
|
||||
});
|
||||
render(<TaskListNav task={buildTask({ id: "t1" })} />);
|
||||
expect(screen.getByLabelText("Previous task")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Next task")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("navigates to the next task on Alt+ArrowRight", () => {
|
||||
useScrollRestorationStore.setState({
|
||||
taskListNav: {
|
||||
items: [
|
||||
{ id: "t1", title: "Task 1" },
|
||||
{ id: "t2", title: "Task 2" },
|
||||
{ id: "t3", title: "Task 3" },
|
||||
],
|
||||
queryString: "status=in_progress",
|
||||
},
|
||||
});
|
||||
render(<TaskListNav task={buildTask({ id: "t2" })} />);
|
||||
|
||||
fireEvent.keyDown(window, { key: "ArrowRight", altKey: true });
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
"/tasks/t3?status=in_progress",
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses Alt+ArrowRight while an input is focused", () => {
|
||||
useScrollRestorationStore.setState({
|
||||
taskListNav: {
|
||||
items: [
|
||||
{ id: "t1", title: "Task 1" },
|
||||
{ id: "t2", title: "Task 2" },
|
||||
{ id: "t3", title: "Task 3" },
|
||||
],
|
||||
queryString: "",
|
||||
},
|
||||
});
|
||||
render(
|
||||
<>
|
||||
<input aria-label="Note field" />
|
||||
<TaskListNav task={buildTask({ id: "t2" })} />
|
||||
</>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Note field");
|
||||
input.focus();
|
||||
fireEvent.keyDown(input, { key: "ArrowRight", altKey: true });
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Task } from "@/types";
|
||||
import { useUpdateTask } from "@/hooks/use-tasks";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Plus, Trash2, Edit3, Check, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { CollapsibleSection } from "./collapsible-section";
|
||||
|
||||
interface AcceptanceCriteriaProps {
|
||||
task: Task;
|
||||
@@ -173,149 +173,147 @@ export function AcceptanceCriteria({ task }: AcceptanceCriteriaProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Plain-text representation used to derive the default collapsed state
|
||||
// per the content-readability spec — a long list starts collapsed.
|
||||
const criteriaText = criteria.map((c) => parseCriterion(c).text).join("\n");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Acceptance Criteria</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{completedCount}/{criteria.length} completed
|
||||
</span>
|
||||
{!isAdding && (
|
||||
<CollapsibleSection
|
||||
title="Acceptance Criteria"
|
||||
content={criteriaText}
|
||||
open={isAdding || editingIndex !== null ? true : undefined}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{completedCount}/{criteria.length} completed
|
||||
</span>
|
||||
{!isAdding && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{criteria.length === 0 && !isAdding ? (
|
||||
<p
|
||||
className="text-muted-foreground italic cursor-pointer hover:bg-muted/30 rounded-md p-2 -m-2 transition-colors"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
No acceptance criteria defined. Click to add one.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{criteria.map((criterion, idx) => {
|
||||
const { text, completed } = parseCriterion(criterion);
|
||||
const isEditingThis = editingIndex === idx;
|
||||
|
||||
return (
|
||||
<li key={idx} className="flex items-center gap-2 group">
|
||||
<Checkbox
|
||||
checked={completed}
|
||||
onCheckedChange={() => toggleCriterion(idx)}
|
||||
disabled={updateTask.isPending || isEditingThis}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
{isEditingThis ? (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<Input
|
||||
ref={editInputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={handleSaveEdit}
|
||||
className="h-8 text-sm flex-1"
|
||||
disabled={updateTask.isPending}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingIndex(null)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveEdit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={`flex-1 cursor-pointer select-none hover:bg-muted/30 px-2 py-1 -mx-2 rounded transition-colors ${
|
||||
completed ? "line-through text-muted-foreground" : ""
|
||||
}`}
|
||||
onClick={() => startEditing(idx)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => startEditing(idx)}
|
||||
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteCriterion(idx)}
|
||||
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdding && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Checkbox checked={false} disabled className="shrink-0" />
|
||||
<Input
|
||||
ref={newInputRef}
|
||||
value={newCriterion}
|
||||
onChange={(e) => setNewCriterion(e.target.value)}
|
||||
onKeyDown={handleAddKeyDown}
|
||||
placeholder="Add criterion..."
|
||||
className="h-8 text-sm flex-1"
|
||||
disabled={updateTask.isPending}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsAdding(true)}
|
||||
onClick={() => {
|
||||
setNewCriterion("");
|
||||
setIsAdding(false);
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{criteria.length === 0 && !isAdding ? (
|
||||
<p
|
||||
className="text-muted-foreground italic cursor-pointer hover:bg-muted/30 rounded-md p-2 -m-2 transition-colors"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
No acceptance criteria defined. Click to add one.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{criteria.map((criterion, idx) => {
|
||||
const { text, completed } = parseCriterion(criterion);
|
||||
const isEditingThis = editingIndex === idx;
|
||||
|
||||
return (
|
||||
<li key={idx} className="flex items-center gap-2 group">
|
||||
<Checkbox
|
||||
checked={completed}
|
||||
onCheckedChange={() => toggleCriterion(idx)}
|
||||
disabled={updateTask.isPending || isEditingThis}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
{isEditingThis ? (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<Input
|
||||
ref={editInputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={handleSaveEdit}
|
||||
className="h-8 text-sm flex-1"
|
||||
disabled={updateTask.isPending}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingIndex(null)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveEdit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={`flex-1 cursor-pointer select-none hover:bg-muted/30 px-2 py-1 -mx-2 rounded transition-colors ${
|
||||
completed ? "line-through text-muted-foreground" : ""
|
||||
}`}
|
||||
onClick={() => startEditing(idx)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => startEditing(idx)}
|
||||
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteCriterion(idx)}
|
||||
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add new criterion input */}
|
||||
{isAdding && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Checkbox checked={false} disabled className="shrink-0" />
|
||||
<Input
|
||||
ref={newInputRef}
|
||||
value={newCriterion}
|
||||
onChange={(e) => setNewCriterion(e.target.value)}
|
||||
onKeyDown={handleAddKeyDown}
|
||||
placeholder="Add criterion..."
|
||||
className="h-8 text-sm flex-1"
|
||||
disabled={updateTask.isPending}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setNewCriterion("");
|
||||
setIsAdding(false);
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddCriterion}
|
||||
disabled={!newCriterion.trim() || updateTask.isPending}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddCriterion}
|
||||
disabled={!newCriterion.trim() || updateTask.isPending}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,21 +4,12 @@ import { Checkpoint } from "@/types";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Bookmark, Clock, User, ListTodo, FileText } from "lucide-react";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { formatAbsoluteTimestamp } from "@/lib/utils";
|
||||
|
||||
interface CheckpointCardProps {
|
||||
checkpoint: Checkpoint;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function CheckpointCard({ checkpoint }: CheckpointCardProps) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
@@ -29,7 +20,7 @@ export function CheckpointCard({ checkpoint }: CheckpointCardProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(checkpoint.timestamp)}
|
||||
{formatAbsoluteTimestamp(checkpoint.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="pt-4">
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { exceedsReadabilityThreshold } from "@/lib/content-readability";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
/** Card title content (icon + text + badges as needed) */
|
||||
title: ReactNode;
|
||||
/** Right-aligned header controls (edit/preview toggles, buttons) — always visible */
|
||||
actions?: ReactNode;
|
||||
/** Controlled open state (e.g. force-open while a section is mid-edit). Omit for uncontrolled. */
|
||||
open?: boolean;
|
||||
/**
|
||||
* Whether the (uncontrolled) section starts expanded. Takes precedence
|
||||
* over `content`-derived collapsing. Omit to let `content` decide, or to
|
||||
* default open when neither is given (so nothing visible today disappears).
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
/**
|
||||
* Plain-text representation of the section's body, used to derive
|
||||
* `defaultOpen` per the content-readability spec (~10 lines / ~640 chars)
|
||||
* when `defaultOpen` is not explicitly set. Ignored otherwise.
|
||||
*/
|
||||
content?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Card whose body can be independently collapsed/expanded, so a task with
|
||||
* many sections (description, notes, plan) doesn't force continuous
|
||||
* scrolling. Collapse/expand is fade + slide (opacity/transform only, via
|
||||
* tw-animate-css's animate-in/out) — no height/width property is animated,
|
||||
* and prefers-reduced-motion is handled globally in globals.css.
|
||||
*
|
||||
* Auto-collapse logic (content-readability-spec):
|
||||
* - If `defaultOpen` is explicitly set, it takes precedence (e.g., force-open while editing)
|
||||
* - Otherwise, if `content` is provided, starts collapsed if content exceeds ~10 lines or ~640 chars
|
||||
* - If neither is set, defaults to true (visible by default, safe for new sections)
|
||||
*
|
||||
* This ensures a task with a long acceptance-criteria list or verbose description
|
||||
* doesn't open fully expanded, keeping the page navigable.
|
||||
*/
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
actions,
|
||||
open: openProp,
|
||||
defaultOpen,
|
||||
content,
|
||||
onOpenChange,
|
||||
className,
|
||||
headerClassName,
|
||||
children,
|
||||
}: CollapsibleSectionProps) {
|
||||
// Resolve the starting state: explicit defaultOpen > content-derived > default to true
|
||||
const resolvedDefaultOpen =
|
||||
defaultOpen ??
|
||||
(content !== undefined ? !exceedsReadabilityThreshold(content) : true);
|
||||
const [internalOpen, setInternalOpen] = useState(resolvedDefaultOpen);
|
||||
const open = openProp ?? internalOpen;
|
||||
const setOpen = (next: boolean) => {
|
||||
onOpenChange?.(next);
|
||||
if (openProp === undefined) setInternalOpen(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="contents">
|
||||
<CardHeader className={cn("pb-3", headerClassName)}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||
!open && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-lg">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
{actions && (
|
||||
<div className="flex shrink-0 items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1",
|
||||
)}
|
||||
>
|
||||
<CardContent>{children}</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export { TaskHeader } from "./task-header";
|
||||
export { TaskBreadcrumb } from "./task-breadcrumb";
|
||||
export { TaskListNav } from "./task-list-nav";
|
||||
export { TaskMetadata } from "./task-metadata";
|
||||
export { TaskDescription } from "./task-description";
|
||||
export { TaskTabs } from "./task-tabs";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { MessageSquare, Clock } from "lucide-react";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { formatAbsoluteTimestamp } from "@/lib/utils";
|
||||
|
||||
interface ProgressTimelineProps {
|
||||
updates: ProgressUpdate[];
|
||||
@@ -82,9 +83,13 @@ export function ProgressTimeline({ updates }: ProgressTimelineProps) {
|
||||
<span className="text-sm font-medium">
|
||||
{getAgentDisplayName(update.agent_id)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<span
|
||||
className="text-xs text-muted-foreground flex items-center gap-1"
|
||||
title={formatAbsoluteTimestamp(update.timestamp)}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(update.timestamp)}
|
||||
{formatTime(update.timestamp)} ·{" "}
|
||||
{formatAbsoluteTimestamp(update.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">{update.message}</p>
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { useState } from "react";
|
||||
import { Task } from "@/types";
|
||||
import { useUpdateTask } from "@/hooks/use-tasks";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { CollapsibleSection } from "./collapsible-section";
|
||||
import { exceedsReadabilityThreshold } from "@/lib/content-readability";
|
||||
import {
|
||||
FileText,
|
||||
Code,
|
||||
@@ -96,13 +97,17 @@ const FIELD_TO_SECTION: Record<NoteField, string> = {
|
||||
doc_notes: "doc",
|
||||
};
|
||||
|
||||
// When the section was last written (apply_structured_note stamps it).
|
||||
// When the section was last written (apply_structured_note stamps it). Falls
|
||||
// back to the task's creation time when content exists but predates the stamp
|
||||
// (older notes written before apply_structured_note started stamping) so a
|
||||
// populated field never renders with no timestamp at all.
|
||||
function writtenAt(task: Task, field: NoteField): string | null {
|
||||
const sections = task.notes_structured as
|
||||
| Record<string, { written_at?: string }>
|
||||
| null
|
||||
| undefined;
|
||||
const stamp = sections?.[FIELD_TO_SECTION[field]]?.written_at;
|
||||
const stamp =
|
||||
sections?.[FIELD_TO_SECTION[field]]?.written_at ?? task.created_at;
|
||||
if (!stamp) return null;
|
||||
const date = new Date(stamp);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
@@ -145,11 +150,14 @@ function EditableNoteCard({
|
||||
bgClass,
|
||||
}: NoteCardProps) {
|
||||
const updateTask = useUpdateTask();
|
||||
const currentValue = task[field];
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localEditValue, setLocalEditValue] = useState("");
|
||||
const [editMode, setEditMode] = useState<"write" | "preview">("write");
|
||||
|
||||
const currentValue = task[field];
|
||||
// Long content starts collapsed; short content starts expanded.
|
||||
const [sectionOpen, setSectionOpen] = useState(() =>
|
||||
!exceedsReadabilityThreshold(currentValue ?? ""),
|
||||
);
|
||||
|
||||
// Display prop value when not editing, local value when editing
|
||||
const editValue = isEditing ? localEditValue : (currentValue ?? "");
|
||||
@@ -200,126 +208,129 @@ function EditableNoteCard({
|
||||
// If no content and not editing, show placeholder
|
||||
if (!currentValue && !isEditing) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
{badge}
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="ghost" onClick={startEditing}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p
|
||||
className="text-muted-foreground italic cursor-pointer hover:bg-muted/30 rounded-md p-2 -m-2 transition-colors"
|
||||
onClick={startEditing}
|
||||
>
|
||||
No {title.toLowerCase()} added yet. Click to add.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CollapsibleSection
|
||||
title={
|
||||
<>
|
||||
{icon}
|
||||
{title}
|
||||
{badge}
|
||||
</>
|
||||
}
|
||||
open={sectionOpen}
|
||||
onOpenChange={setSectionOpen}
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" onClick={startEditing}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p
|
||||
className="text-muted-foreground italic cursor-pointer hover:bg-muted/30 rounded-md p-2 -m-2 transition-colors"
|
||||
onClick={startEditing}
|
||||
>
|
||||
No {title.toLowerCase()} added yet. Click to add.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
{badge}
|
||||
<WrittenAtStamp task={task} field={field} />
|
||||
</CardTitle>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs
|
||||
value={editMode}
|
||||
onValueChange={(v) => setEditMode(v as "write" | "preview")}
|
||||
>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="write" className="text-xs px-2 h-6">
|
||||
<Edit3 className="h-3 w-3 mr-1" />
|
||||
Write
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="text-xs px-2 h-6">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
disabled={updateTask.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateTask.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" onClick={startEditing}>
|
||||
<Edit3 className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
<CollapsibleSection
|
||||
title={
|
||||
<>
|
||||
{icon}
|
||||
{title}
|
||||
{badge}
|
||||
<WrittenAtStamp task={task} field={field} />
|
||||
</>
|
||||
}
|
||||
open={isEditing || sectionOpen}
|
||||
onOpenChange={setSectionOpen}
|
||||
content={currentValue ?? undefined}
|
||||
actions={
|
||||
isEditing ? (
|
||||
<>
|
||||
<Tabs
|
||||
value={editMode}
|
||||
onValueChange={(v) => setEditMode(v as "write" | "preview")}
|
||||
>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="write" className="text-xs px-2 h-6">
|
||||
<Edit3 className="h-3 w-3 mr-1" />
|
||||
Write
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="text-xs px-2 h-6">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
disabled={updateTask.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
{editMode === "write" ? (
|
||||
<Textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Add ${title.toLowerCase()}...`}
|
||||
className="min-h-[150px] font-mono text-sm"
|
||||
disabled={updateTask.isPending}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`min-h-[150px] p-4 rounded-lg ${bgClass ?? "bg-muted/50"}`}
|
||||
>
|
||||
{editValue ? (
|
||||
<Markdown className="text-sm">{editValue}</Markdown>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
Nothing to preview
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Markdown supported. Press Ctrl/Cmd + Enter to save, Escape to
|
||||
cancel.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateTask.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={`rounded-lg p-4 cursor-pointer hover:opacity-80 transition-opacity ${bgClass ?? "bg-muted/50"}`}
|
||||
onClick={startEditing}
|
||||
title="Click to edit"
|
||||
>
|
||||
<Markdown className="text-sm">{currentValue!}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button size="sm" variant="ghost" onClick={startEditing}>
|
||||
<Edit3 className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
{editMode === "write" ? (
|
||||
<Textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Add ${title.toLowerCase()}...`}
|
||||
className="min-h-[150px] font-mono text-sm"
|
||||
disabled={updateTask.isPending}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`min-h-[150px] p-4 rounded-lg ${bgClass ?? "bg-muted/50"}`}
|
||||
>
|
||||
{editValue ? (
|
||||
<Markdown className="text-sm">{editValue}</Markdown>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
Nothing to preview
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Markdown supported. Press Ctrl/Cmd + Enter to save, Escape to
|
||||
cancel.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`rounded-lg p-4 cursor-pointer hover:opacity-80 transition-opacity ${bgClass ?? "bg-muted/50"}`}
|
||||
onClick={startEditing}
|
||||
title="Click to edit"
|
||||
>
|
||||
<Markdown className="text-sm">{currentValue!}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Clock,
|
||||
Bookmark,
|
||||
@@ -19,9 +24,36 @@ import {
|
||||
User,
|
||||
ListTodo,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { formatAbsoluteTimestamp } from "@/lib/utils";
|
||||
import { exceedsReadabilityThreshold } from "@/lib/content-readability";
|
||||
|
||||
/**
|
||||
* Only the 2 most recent entries default to expanded; older entries (and
|
||||
* any entry whose own content is long, per the content-readability spec)
|
||||
* default to collapsed so a task with a long history stays navigable.
|
||||
*
|
||||
* Rationale: A task with 30+ progress updates would fill the entire viewport
|
||||
* if all were expanded. This dual-threshold approach keeps the latest work
|
||||
* visible (recent 2) while collapsing older/verbose entries, so a user can
|
||||
* focus on current progress without continuous scrolling through historical details.
|
||||
*/
|
||||
const RECENT_OPEN_COUNT = 2;
|
||||
|
||||
/**
|
||||
* Determine if a progress/checkpoint entry should start expanded.
|
||||
* Applies two thresholds:
|
||||
* 1. Recency: only the 2 most recent entries start open
|
||||
* 2. Content length: an entry starting at index 0 or 1 still collapses if its
|
||||
* own content exceeds ~10 lines or ~640 chars (see content-readability.ts)
|
||||
*/
|
||||
function defaultEntryOpen(idx: number, content: string): boolean {
|
||||
if (idx >= RECENT_OPEN_COUNT) return false;
|
||||
return !exceedsReadabilityThreshold(content);
|
||||
}
|
||||
|
||||
interface TabProgressProps {
|
||||
task: Task;
|
||||
@@ -155,7 +187,6 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add new update form */}
|
||||
{isAdding && (
|
||||
<div className="border rounded-lg p-4 mb-4 space-y-3">
|
||||
<Textarea
|
||||
@@ -208,26 +239,39 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
|
||||
</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
|
||||
|
||||
<ul className="space-y-4">
|
||||
{sortedUpdates.map((update, idx) => (
|
||||
<li key={idx} className="relative pl-8 group">
|
||||
{/* Timeline dot */}
|
||||
<div className="absolute left-0 top-1.5 w-6 h-6 rounded-full bg-background border-2 border-primary flex items-center justify-center">
|
||||
<MessageSquare className="h-3 w-3 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<Collapsible
|
||||
defaultOpen={defaultEntryOpen(idx, update.message)}
|
||||
className="bg-muted/50 rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">
|
||||
{getAgentDisplayName(update.agent_id)}
|
||||
</span>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group/trigger flex min-w-0 items-center gap-1 text-left"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=closed]/trigger:-rotate-90" />
|
||||
<span className="text-sm font-medium">
|
||||
{getAgentDisplayName(update.agent_id)}
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<span
|
||||
className="text-xs text-muted-foreground flex items-center gap-1"
|
||||
title={formatAbsoluteTimestamp(update.timestamp)}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(update.timestamp)}
|
||||
{formatTime(update.timestamp)} ·{" "}
|
||||
{formatAbsoluteTimestamp(update.timestamp)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -239,21 +283,23 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">{update.message}</p>
|
||||
{update.percentage !== null && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={update.percentage}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-10 text-right">
|
||||
{update.percentage}%
|
||||
</span>
|
||||
<CollapsibleContent>
|
||||
<p className="text-sm">{update.message}</p>
|
||||
{update.percentage !== null && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={update.percentage}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-10 text-right">
|
||||
{update.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -358,7 +404,6 @@ function CheckpointsSection({ task }: { task: Task }) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Add new checkpoint form */}
|
||||
{isAdding && (
|
||||
<div className="border rounded-lg p-4 mb-4 space-y-3">
|
||||
<div>
|
||||
@@ -430,75 +475,106 @@ function CheckpointsSection({ task }: { task: Task }) {
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedCheckpoints.map((checkpoint) => (
|
||||
<Card key={checkpoint.id} className="overflow-hidden group">
|
||||
<div className="bg-primary/10 px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bookmark className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-sm">Checkpoint</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(checkpoint.timestamp)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(checkpoint.id)}
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="pt-4">
|
||||
{/* Agent */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||
<User className="h-4 w-4" />
|
||||
<span>
|
||||
Saved by {getAgentDisplayName(checkpoint.agent_id)}
|
||||
</span>
|
||||
</div>
|
||||
{sortedCheckpoints.map((checkpoint, idx) => {
|
||||
const checkpointContent = [
|
||||
checkpoint.state_summary,
|
||||
...checkpoint.remaining_work,
|
||||
checkpoint.notes ?? "",
|
||||
].join("\n");
|
||||
|
||||
{/* State Summary */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium mb-1">State Summary</h4>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{checkpoint.state_summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remaining Work */}
|
||||
{checkpoint.remaining_work.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">Remaining Work</h4>
|
||||
return (
|
||||
<Card
|
||||
key={checkpoint.id}
|
||||
className="overflow-hidden group py-0"
|
||||
>
|
||||
<Collapsible
|
||||
defaultOpen={defaultEntryOpen(idx, checkpointContent)}
|
||||
>
|
||||
<div className="bg-primary/10 px-4 py-2 flex items-center justify-between">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group/trigger flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=closed]/trigger:-rotate-90" />
|
||||
<Bookmark className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-sm">
|
||||
Checkpoint
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs text-muted-foreground flex items-center gap-1"
|
||||
title={formatAbsoluteTimestamp(checkpoint.timestamp)}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(checkpoint.timestamp)} ·{" "}
|
||||
{formatAbsoluteTimestamp(checkpoint.timestamp)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(checkpoint.id)}
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
{checkpoint.remaining_work.map((item, idx) => (
|
||||
<li key={idx}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||
<User className="h-4 w-4" />
|
||||
<span>
|
||||
Saved by {getAgentDisplayName(checkpoint.agent_id)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{checkpoint.notes && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">Notes</h4>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{checkpoint.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium mb-1">
|
||||
State Summary
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{checkpoint.state_summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{checkpoint.remaining_work.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">
|
||||
Remaining Work
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
{checkpoint.remaining_work.map(
|
||||
(item, itemIdx) => (
|
||||
<li key={itemIdx}>{item}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkpoint.notes && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">Notes</h4>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{checkpoint.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -512,12 +588,10 @@ function CheckpointsSection({ task }: { task: Task }) {
|
||||
export function TabProgress({ task }: TabProgressProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Progress Updates Column */}
|
||||
<div>
|
||||
<ProgressUpdatesSection task={task} />
|
||||
</div>
|
||||
|
||||
{/* Checkpoints Column */}
|
||||
<div>
|
||||
<CheckpointsSection task={task} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Task } from "@/types";
|
||||
import { useTask } from "@/hooks/use-tasks";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface TaskBreadcrumbProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
// Renders "Parent title >" above the task title whenever this task has a
|
||||
// parent — absent (returns null) for a root task. Only the immediate parent
|
||||
// is shown; deeper ancestry is reachable by following the chain one hop at a
|
||||
// time, matching how the rest of the panel represents task hierarchy.
|
||||
export function TaskBreadcrumb({ task }: TaskBreadcrumbProps) {
|
||||
const parentId = task.parent_task_id;
|
||||
const { data: parent, isLoading } = useTask(parentId ?? "");
|
||||
|
||||
if (!parentId) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Parent task"
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0"
|
||||
>
|
||||
{isLoading || !parent ? (
|
||||
<Skeleton className="h-4 w-32" />
|
||||
) : (
|
||||
<Link
|
||||
href={`/tasks/${parent.id}`}
|
||||
prefetch={false}
|
||||
className="truncate hover:text-foreground hover:underline"
|
||||
title={parent.title}
|
||||
>
|
||||
{parent.title}
|
||||
</Link>
|
||||
)}
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate text-foreground/70" title={task.title}>
|
||||
{task.title}
|
||||
</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useState } from "react";
|
||||
import { Task } from "@/types";
|
||||
import { useUpdateTask } from "@/hooks/use-tasks";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { Edit3, Eye, Check, X } from "lucide-react";
|
||||
import { CollapsibleSection } from "./collapsible-section";
|
||||
import { Edit3, Eye, Check, X, ShieldAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface TaskDescriptionProps {
|
||||
@@ -20,6 +20,7 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localEditValue, setLocalEditValue] = useState("");
|
||||
const [editMode, setEditMode] = useState<"write" | "preview">("write");
|
||||
const [sectionOpen, setSectionOpen] = useState(true);
|
||||
|
||||
// Display prop value when not editing, local value when editing
|
||||
const editValue = isEditing ? localEditValue : task.description;
|
||||
@@ -79,12 +80,13 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Description</CardTitle>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CollapsibleSection
|
||||
title="Description"
|
||||
open={isEditing || sectionOpen}
|
||||
onOpenChange={setSectionOpen}
|
||||
actions={
|
||||
isEditing ? (
|
||||
<>
|
||||
<Tabs
|
||||
value={editMode}
|
||||
onValueChange={(v) => setEditMode(v as "write" | "preview")}
|
||||
@@ -116,16 +118,15 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" onClick={startEditing}>
|
||||
<Edit3 className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
)
|
||||
}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
{editMode === "write" ? (
|
||||
@@ -176,24 +177,26 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
|
||||
No description provided. Click to add one.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{task.constraints ? (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base text-muted-foreground">
|
||||
Constraints
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</CollapsibleSection>
|
||||
{task.constraints ? (
|
||||
<CollapsibleSection
|
||||
title={
|
||||
<>
|
||||
<ShieldAlert className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-amber-800 dark:text-amber-300">
|
||||
Constraints
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
className="border-amber-300 bg-amber-50/60 dark:border-amber-800 dark:bg-amber-950/20"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Architectural standard derived from the project conventions —
|
||||
read-only. Applies to every task in this project.
|
||||
</p>
|
||||
<Markdown>{task.constraints}</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</CollapsibleSection>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
ArrowLeft,
|
||||
MoreVertical,
|
||||
Play,
|
||||
Pause,
|
||||
@@ -52,7 +51,6 @@ import {
|
||||
ThumbsDown,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { TaskTypeBadge } from "../task-type-badge";
|
||||
import { CopyButton } from "@/components/ui/copy-button";
|
||||
|
||||
@@ -468,15 +466,10 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
|
||||
return (
|
||||
<div className="border-b pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left: back arrow + title + metadata. This column SHRINKS and the
|
||||
title truncates, so a long title never pushes the controls or the
|
||||
{/* Left: title + metadata. This column SHRINKS and the title
|
||||
truncates, so a long title never pushes the controls or the
|
||||
Actions menu out of place. */}
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<Link href="/tasks" prefetch={false}>
|
||||
<Button variant="ghost" size="icon" className="shrink-0">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Row 1: title only — editable, no UUID. Truncates on overflow. */}
|
||||
{editingTitle ? (
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Task } from "@/types";
|
||||
import { useScrollRestorationStore } from "@/lib/stores";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface TaskListNavProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
const NO_CONTEXT_TOOLTIP =
|
||||
"Open this task from the Tasks list to enable prev/next navigation within that list's filter/sort order.";
|
||||
|
||||
// Don't hijack Alt+Arrow while the user is typing (input/textarea/contenteditable).
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
// Moves to the adjacent task within the Tasks list order the user last
|
||||
// visited (filters + sort applied), captured in useScrollRestorationStore by
|
||||
// the Tasks list page. Documented fallback: when no list context exists for
|
||||
// this session (task opened via a direct link, search result, notification,
|
||||
// etc.) or the current task isn't part of the captured order (it was
|
||||
// navigated to some other way), both buttons render disabled with a tooltip
|
||||
// explaining why — there is no list order to fall back to, so we don't guess.
|
||||
export function TaskListNav({ task }: TaskListNavProps) {
|
||||
const router = useRouter();
|
||||
const context = useScrollRestorationStore((state) => state.taskListNav);
|
||||
|
||||
const items = context?.items ?? [];
|
||||
const index = items.findIndex((item) => item.id === task.id);
|
||||
const hasContext = index !== -1;
|
||||
const prevItem = hasContext && index > 0 ? items[index - 1] : null;
|
||||
const nextItem =
|
||||
hasContext && index < items.length - 1 ? items[index + 1] : null;
|
||||
|
||||
const query = context?.queryString ? `?${context.queryString}` : "";
|
||||
|
||||
// Alt+ArrowLeft/Right mirrors the prev/next buttons above.
|
||||
useEffect(() => {
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (!e.altKey || isEditableTarget(e.target)) return;
|
||||
if (e.key === "ArrowLeft" && prevItem) {
|
||||
e.preventDefault();
|
||||
router.push(`/tasks/${prevItem.id}${query}`);
|
||||
} else if (e.key === "ArrowRight" && nextItem) {
|
||||
e.preventDefault();
|
||||
router.push(`/tasks/${nextItem.id}${query}`);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [prevItem, nextItem, query, router]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<NavButton
|
||||
direction="prev"
|
||||
item={prevItem}
|
||||
query={query}
|
||||
disabledReason={!hasContext ? NO_CONTEXT_TOOLTIP : undefined}
|
||||
/>
|
||||
<NavButton
|
||||
direction="next"
|
||||
item={nextItem}
|
||||
query={query}
|
||||
disabledReason={!hasContext ? NO_CONTEXT_TOOLTIP : undefined}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
direction,
|
||||
item,
|
||||
query,
|
||||
disabledReason,
|
||||
}: {
|
||||
direction: "prev" | "next";
|
||||
item: { id: string; title: string } | null;
|
||||
query: string;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
|
||||
const label = direction === "prev" ? "Previous task" : "Next task";
|
||||
const disabled = item === null;
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
asChild={!disabled}
|
||||
>
|
||||
{disabled ? (
|
||||
<Icon className="h-4 w-4" />
|
||||
) : (
|
||||
<Link href={`/tasks/${item.id}${query}`} prefetch={false}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const tooltipText = disabledReason ?? item?.title ?? label;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{button}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Task } from "@/types";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -95,6 +95,10 @@ interface TaskTableProps {
|
||||
// Controlled expanded state
|
||||
expandedIds?: Set<string>;
|
||||
onExpandedChange?: (ids: Set<string>) => void;
|
||||
// Reports the currently visible, filtered + sorted task order (id + title
|
||||
// pairs) — consumed by the Tasks list page to power task-detail prev/next
|
||||
// navigation. Fires whenever the computed order changes.
|
||||
onVisibleOrderChange?: (items: { id: string; title: string }[]) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||
@@ -268,6 +272,7 @@ export function TaskTable({
|
||||
onPageSizeChange,
|
||||
expandedIds: controlledExpandedIds,
|
||||
onExpandedChange,
|
||||
onVisibleOrderChange,
|
||||
}: TaskTableProps) {
|
||||
// Internal state (used when not controlled)
|
||||
const [internalSortConfig, setInternalSortConfig] =
|
||||
@@ -384,6 +389,17 @@ export function TaskTable({
|
||||
return flattenTree(sortedRoots, expandedIds);
|
||||
}, [sortedRoots, expandedIds]);
|
||||
|
||||
// Report the visible order to the parent so task-detail can compute
|
||||
// prev/next within this exact filter/sort context.
|
||||
useEffect(() => {
|
||||
onVisibleOrderChange?.(
|
||||
flattenedTasks.map((node) => ({
|
||||
id: node.task.id,
|
||||
title: node.task.title,
|
||||
})),
|
||||
);
|
||||
}, [flattenedTasks, onVisibleOrderChange]);
|
||||
|
||||
// Pagination on flattened list
|
||||
const totalItems = flattenedTasks.length;
|
||||
const totalPages = Math.ceil(totalItems / pageSize);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
exceedsReadabilityThreshold,
|
||||
READABILITY_LINE_THRESHOLD,
|
||||
READABILITY_CHAR_THRESHOLD,
|
||||
} from "@/lib/content-readability";
|
||||
|
||||
describe("exceedsReadabilityThreshold", () => {
|
||||
it("is false for empty or short content", () => {
|
||||
expect(exceedsReadabilityThreshold("")).toBe(false);
|
||||
expect(exceedsReadabilityThreshold("A single short line.")).toBe(false);
|
||||
});
|
||||
|
||||
it("is true once the line count exceeds the threshold", () => {
|
||||
const lines = Array(READABILITY_LINE_THRESHOLD + 1)
|
||||
.fill("x")
|
||||
.join("\n");
|
||||
expect(exceedsReadabilityThreshold(lines)).toBe(true);
|
||||
});
|
||||
|
||||
it("is true once the character count exceeds the threshold", () => {
|
||||
const long = "a".repeat(READABILITY_CHAR_THRESHOLD + 1);
|
||||
expect(exceedsReadabilityThreshold(long)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false right at the thresholds", () => {
|
||||
const atLineLimit = Array(READABILITY_LINE_THRESHOLD).fill("x").join("\n");
|
||||
expect(exceedsReadabilityThreshold(atLineLimit)).toBe(false);
|
||||
const atCharLimit = "a".repeat(READABILITY_CHAR_THRESHOLD);
|
||||
expect(exceedsReadabilityThreshold(atCharLimit)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared thresholds for auto-collapsing long content in the task detail
|
||||
* view (CollapsibleSection, progress updates, checkpoints, acceptance
|
||||
* criteria) so a task with a long history doesn't force continuous
|
||||
* scrolling through fully-expanded sections.
|
||||
*
|
||||
* Usage:
|
||||
* - CollapsibleSection passes `content` prop; component auto-collapses if content exceeds thresholds
|
||||
* - TabProgress (ProgressUpdatesSection, CheckpointsSection) calls exceedsReadabilityThreshold() directly
|
||||
* to decide if an entry defaults open (only 2 most recent open by default; others checked against thresholds)
|
||||
* - AcceptanceCriteria passes criteria text to CollapsibleSection
|
||||
*
|
||||
* Examples:
|
||||
* - "Step 1: Do X\nStep 2: Do Y" (2 lines, 26 chars) → starts expanded
|
||||
* - "Detailed description of implementation...\n...[11+ lines or >640 chars]" → starts collapsed
|
||||
*/
|
||||
export const READABILITY_LINE_THRESHOLD = 10;
|
||||
export const READABILITY_CHAR_THRESHOLD = 640;
|
||||
|
||||
/**
|
||||
* True when content exceeds readability thresholds and should default to
|
||||
* collapsed to keep the page scrollable on long tasks.
|
||||
*
|
||||
* A task with 30+ progress entries or 20+ acceptance criteria can fill the
|
||||
* entire viewport when all sections are expanded. This function prevents
|
||||
* that by collapsing sections with long individual entries, keeping the
|
||||
* most-recent few visible while older/verbose entries require a click to expand.
|
||||
*/
|
||||
export function exceedsReadabilityThreshold(content: string): boolean {
|
||||
if (!content) return false;
|
||||
const lineCount = content.split("\n").length;
|
||||
return (
|
||||
lineCount > READABILITY_LINE_THRESHOLD ||
|
||||
content.length > READABILITY_CHAR_THRESHOLD
|
||||
);
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
export { useScrollRestorationStore } from "./scroll-restoration-store";
|
||||
export type {
|
||||
TaskListNavContext,
|
||||
TaskListNavItem,
|
||||
} from "./scroll-restoration-store";
|
||||
|
||||
@@ -15,6 +15,21 @@ interface ScrollPosition {
|
||||
y: number;
|
||||
}
|
||||
|
||||
// A single entry in the current Tasks list order (filter + sort applied),
|
||||
// captured by the Tasks list page so the task-detail page can compute
|
||||
// prev/next without re-implementing the list's filter/sort logic.
|
||||
export interface TaskListNavItem {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TaskListNavContext {
|
||||
items: TaskListNavItem[];
|
||||
// The Tasks list's current query string (filters/sort/search), so a "Back
|
||||
// to list" link can restore the exact context the user navigated from.
|
||||
queryString: string;
|
||||
}
|
||||
|
||||
interface ScrollRestorationState {
|
||||
// Scroll positions per route
|
||||
scrollPositions: Record<string, ScrollPosition>;
|
||||
@@ -28,6 +43,11 @@ interface ScrollRestorationState {
|
||||
// Last visited routes per section (for "back" behavior)
|
||||
lastVisited: Record<string, string>;
|
||||
|
||||
// Current Tasks list order (filter/sort context) for task-detail prev/next
|
||||
// navigation. Null when no list has been visited this session — the
|
||||
// documented fallback for task-detail is to disable prev/next then.
|
||||
taskListNav: TaskListNavContext | null;
|
||||
|
||||
// Actions
|
||||
setScrollPosition: (route: string, position: ScrollPosition) => void;
|
||||
getScrollPosition: (route: string) => ScrollPosition | undefined;
|
||||
@@ -42,6 +62,8 @@ interface ScrollRestorationState {
|
||||
|
||||
setLastVisited: (section: string, route: string) => void;
|
||||
getLastVisited: (section: string) => string | undefined;
|
||||
|
||||
setTaskListNav: (context: TaskListNavContext) => void;
|
||||
}
|
||||
|
||||
export const useScrollRestorationStore = create<ScrollRestorationState>()(
|
||||
@@ -51,6 +73,7 @@ export const useScrollRestorationStore = create<ScrollRestorationState>()(
|
||||
expandedSections: {},
|
||||
selectedItems: {},
|
||||
lastVisited: {},
|
||||
taskListNav: null,
|
||||
|
||||
// Scroll position management
|
||||
setScrollPosition: (route, position) =>
|
||||
@@ -102,6 +125,8 @@ export const useScrollRestorationStore = create<ScrollRestorationState>()(
|
||||
})),
|
||||
|
||||
getLastVisited: (section) => get().lastVisited[section],
|
||||
|
||||
setTaskListNav: (context) => set({ taskListNav: context }),
|
||||
}),
|
||||
{
|
||||
name: "roboco-ui-state",
|
||||
@@ -111,6 +136,7 @@ export const useScrollRestorationStore = create<ScrollRestorationState>()(
|
||||
scrollPositions: state.scrollPositions,
|
||||
expandedSections: state.expandedSections,
|
||||
lastVisited: state.lastVisited,
|
||||
taskListNav: state.taskListNav,
|
||||
// Don't persist selectedItems - they're temporary
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -4,3 +4,16 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Absolute inline timestamp shown alongside relative "Xm ago" text — e.g.
|
||||
// "Jul 10, 2026, 3:45 PM". Shared so progress updates and checkpoints render
|
||||
// the same format instead of each screen inventing its own.
|
||||
export function formatAbsoluteTimestamp(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user