Skip to content

Commit b35e6cb

Browse files
committed
feat: show value preview ghost rows on board page near-empty state
Replace process-oriented encouragement card with value-oriented copy and 2-3 ghost/example task rows that preview what the board looks like when populated. Ghost rows show marketing-relevant examples with reduced opacity, EXAMPLE badges, and pointer-events-none styling.
1 parent 0d9a2f8 commit b35e6cb

File tree

5 files changed

+273
-5
lines changed

5 files changed

+273
-5
lines changed

apps/desktop/src/features/board/components/BoardWorkspace.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { useCallback, useState } from "react";
1+
import { useCallback, useMemo, useState } from "react";
22
import { AlertCircleIcon, LayoutDashboardIcon, ListChecksIcon, MessageSquareIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
33
import { Button } from "@/components/ui/button";
44
import type { SidecarClient } from "@/lib/sidecar/client";
55
import { useTaskList } from "@/features/board/hooks/useTaskList";
66
import { useLeadingTask } from "@/features/board/hooks/useLeadingTask";
77
import { useBoardFilters } from "@/features/board/hooks/useBoardFilters";
8+
import { getGhostTasks } from "@/features/board/lib/ghost-tasks";
89
import { TaskList, TaskListSkeleton } from "./TaskList";
910
import { TaskDetailPanel } from "./TaskDetailPanel";
1011
import { LeadingTaskCard } from "./LeadingTaskCard";
@@ -99,6 +100,11 @@ function BoardContent({
99100
refreshLeading();
100101
}, [refresh, refreshLeading]);
101102

103+
const ghostTasks = useMemo(
104+
() => getGhostTasks(filteredTasks.length),
105+
[filteredTasks.length],
106+
);
107+
102108
return (
103109
<div className="flex flex-1 flex-col overflow-y-auto p-5 lg:p-6">
104110
{!isLoading && !error && tasks.length > 0 && (
@@ -211,6 +217,7 @@ function BoardContent({
211217
<TaskList
212218
tasks={filteredTasks}
213219
groups={groupedTasks}
220+
ghostTasks={ghostTasks}
214221
leadingTaskId={leadingTask?.taskId ?? null}
215222
onTaskSelect={(id) => setSelectedTaskId(id)}
216223
onSetLeadingTask={handleSetLeadingTask}
@@ -219,14 +226,14 @@ function BoardContent({
219226
{filteredTasks.length <= 3 && (
220227
<div className="mt-6 flex items-start gap-3 rounded-lg border border-primary/10 bg-primary/[0.02] px-4 py-3.5 dark:border-primary/[0.06] dark:bg-primary/[0.015]">
221228
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 ring-1 ring-primary/10">
222-
<MessageSquareIcon className="size-4 text-primary/70" />
229+
<ListChecksIcon className="size-4 text-primary/70" />
223230
</div>
224231
<div className="space-y-1">
225232
<p className="text-[12px] font-medium text-foreground/80">
226-
Create tasks from chat or actions
233+
Your follow-up tasks appear here
227234
</p>
228235
<p className="text-[11px] leading-relaxed text-muted-foreground/60">
229-
Ask your CMO or specialists to plan work, and tasks will appear here automatically. You can also run actions from the dashboard.
236+
As you run marketing jobs, follow-up tasks appear here for review and action. Run a job from the dashboard or ask a specialist in chat.
230237
</p>
231238
<div className="mt-2 flex items-center gap-2">
232239
<Button asChild size="xs">

apps/desktop/src/features/board/components/TaskList.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ReviewIndicator } from "./ReviewIndicator";
1616
import { formatRelativeTime } from "@/features/board/lib/format-relative-time";
1717
import { sanitizeTaskTitle } from "@/features/board/lib/sanitize-task-title";
1818
import type { TaskGroup } from "@/features/board/lib/board-grouping";
19+
import { GHOST_TASK_PREFIX } from "@/features/board/lib/ghost-tasks";
1920

2021
// ---------------------------------------------------------------------------
2122
// TaskRow (simplified)
@@ -101,6 +102,48 @@ function TaskRow({ task, isLeading, onSelect, onSetLeadingTask }: TaskRowProps)
101102
);
102103
}
103104

105+
// ---------------------------------------------------------------------------
106+
// GhostRow (preview example rows)
107+
// ---------------------------------------------------------------------------
108+
109+
function GhostRow({ task }: { task: TaskRecord }) {
110+
return (
111+
<TableRow className="pointer-events-none border-border/10">
112+
{/* Title */}
113+
<TableCell className="max-w-[400px] py-3 pl-5 text-[13px] font-medium text-foreground/25">
114+
<div className="flex items-center gap-2 overflow-hidden">
115+
<span className="truncate">{sanitizeTaskTitle(task.title)}</span>
116+
<span className="inline-flex shrink-0 items-center rounded-sm border border-dashed border-primary/20 bg-primary/[0.04] px-1.5 py-px font-mono text-[9px] font-semibold uppercase tracking-widest text-primary/40">
117+
EXAMPLE
118+
</span>
119+
</div>
120+
</TableCell>
121+
122+
{/* Status */}
123+
<TableCell className="py-3 opacity-25">
124+
<TaskStatusBadge status={task.status} />
125+
</TableCell>
126+
127+
{/* Context */}
128+
<TableCell className="py-3">
129+
<span className="text-muted-foreground/15">&mdash;</span>
130+
</TableCell>
131+
132+
{/* Updated */}
133+
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground/15 tabular-nums">
134+
&mdash;
135+
</TableCell>
136+
137+
{/* Owner */}
138+
<TableCell className="py-3">
139+
<span className="font-mono text-[11px] text-muted-foreground/20 uppercase tracking-wider">
140+
{task.owner || "\u2014"}
141+
</span>
142+
</TableCell>
143+
</TableRow>
144+
);
145+
}
146+
104147
// ---------------------------------------------------------------------------
105148
// GroupHeader
106149
// ---------------------------------------------------------------------------
@@ -156,6 +199,7 @@ function GroupHeader({ label, count, isExpanded, onToggle }: GroupHeaderProps) {
156199
interface TaskListProps {
157200
tasks: TaskRecord[];
158201
groups?: TaskGroup[];
202+
ghostTasks?: TaskRecord[];
159203
leadingTaskId?: string | null;
160204
onTaskSelect: (taskId: string) => void;
161205
onSetLeadingTask?: (taskId: string) => void;
@@ -164,11 +208,13 @@ interface TaskListProps {
164208
export function TaskList({
165209
tasks,
166210
groups,
211+
ghostTasks,
167212
leadingTaskId,
168213
onTaskSelect,
169214
onSetLeadingTask,
170215
}: TaskListProps) {
171216
const isGrouped = groups && groups.length > 0 && !(groups.length === 1 && groups[0]!.key === "__all__");
217+
const showGhosts = ghostTasks && ghostTasks.length > 0 && !isGrouped;
172218

173219
return (
174220
<div className="flex-1 overflow-y-auto">
@@ -204,6 +250,10 @@ export function TaskList({
204250
/>
205251
))
206252
)}
253+
{showGhosts &&
254+
ghostTasks.map((ghost) => (
255+
<GhostRow key={ghost.taskId} task={ghost} />
256+
))}
207257
</TableBody>
208258
</Table>
209259
</div>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { TaskRecord } from "@opengoat/contracts";
2+
3+
export const GHOST_TASK_PREFIX = "__ghost__";
4+
5+
const GHOST_TASKS: TaskRecord[] = [
6+
{
7+
taskId: `${GHOST_TASK_PREFIX}1`,
8+
title: "Homepage hero rewrite — review & approve",
9+
description: "Review the updated homepage hero copy generated from the Website Conversion workflow.",
10+
status: "pending",
11+
owner: "Website Conversion",
12+
assignedTo: "",
13+
createdAt: "",
14+
updatedAt: "",
15+
statusReason: undefined,
16+
blockers: [],
17+
artifacts: [],
18+
worklog: [],
19+
},
20+
{
21+
taskId: `${GHOST_TASK_PREFIX}2`,
22+
title: "SEO proof pages — publish to blog",
23+
description: "Publish the generated SEO/AEO content pages after review.",
24+
status: "todo",
25+
owner: "SEO / AEO",
26+
assignedTo: "",
27+
createdAt: "",
28+
updatedAt: "",
29+
statusReason: undefined,
30+
blockers: [],
31+
artifacts: [],
32+
worklog: [],
33+
},
34+
{
35+
taskId: `${GHOST_TASK_PREFIX}3`,
36+
title: "Product Hunt launch checklist — complete before launch",
37+
description: "Review and complete the pre-launch distribution checklist.",
38+
status: "todo",
39+
owner: "Distribution",
40+
assignedTo: "",
41+
createdAt: "",
42+
updatedAt: "",
43+
statusReason: undefined,
44+
blockers: [],
45+
artifacts: [],
46+
worklog: [],
47+
},
48+
];
49+
50+
/**
51+
* Returns ghost/example task rows for the board preview.
52+
* Shows up to 3 example rows when the board has fewer than 3 real tasks.
53+
*/
54+
export function getGhostTasks(realTaskCount: number): TaskRecord[] {
55+
const needed = Math.max(0, 3 - realTaskCount);
56+
return GHOST_TASKS.slice(0, needed);
57+
}

test/ui/board-empty-state-cta.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ describe("Board empty state CTA buttons", () => {
7878
describe("CTA in promotional banner", () => {
7979
it("promotional banner also includes action links", () => {
8080
const bannerTextIdx = boardWorkspaceSrc.indexOf(
81-
"Create tasks from chat or actions",
81+
"Your follow-up tasks appear here",
8282
);
8383
expect(bannerTextIdx).toBeGreaterThan(-1);
8484
// After the banner text, there should be CTA buttons before the section closes

test/ui/board-ghost-tasks.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { beforeAll, describe, expect, it } from "vitest";
2+
import { readFileSync } from "node:fs";
3+
import { resolve } from "node:path";
4+
5+
const boardWorkspaceSrc = readFileSync(
6+
resolve(
7+
__dirname,
8+
"../../apps/desktop/src/features/board/components/BoardWorkspace.tsx",
9+
),
10+
"utf-8",
11+
);
12+
13+
const taskListSrc = readFileSync(
14+
resolve(
15+
__dirname,
16+
"../../apps/desktop/src/features/board/components/TaskList.tsx",
17+
),
18+
"utf-8",
19+
);
20+
21+
const ghostTasksSrc = readFileSync(
22+
resolve(
23+
__dirname,
24+
"../../apps/desktop/src/features/board/lib/ghost-tasks.ts",
25+
),
26+
"utf-8",
27+
);
28+
29+
// ---------------------------------------------------------------------------
30+
// Ghost task data module
31+
// ---------------------------------------------------------------------------
32+
33+
describe("Ghost tasks data module", () => {
34+
it("exports a getGhostTasks function", () => {
35+
expect(ghostTasksSrc).toMatch(/export\s+function\s+getGhostTasks/);
36+
});
37+
38+
it("exports the GHOST_TASK_PREFIX constant", () => {
39+
expect(ghostTasksSrc).toMatch(/export\s+const\s+GHOST_TASK_PREFIX/);
40+
});
41+
42+
it("contains marketing-relevant example task titles", () => {
43+
// At least one example about homepage/website conversion
44+
expect(ghostTasksSrc).toMatch(/homepage|hero|rewrite|website/i);
45+
// At least one about SEO/content
46+
expect(ghostTasksSrc).toMatch(/seo|blog|content|pages/i);
47+
// At least one about distribution/launch
48+
expect(ghostTasksSrc).toMatch(/launch|distribution|product hunt|checklist/i);
49+
});
50+
51+
it("returns fewer ghost tasks as real task count increases", () => {
52+
// The function takes a count and returns 3 - count (capped at 0)
53+
expect(ghostTasksSrc).toMatch(/Math\.max|3\s*-/);
54+
});
55+
});
56+
57+
// ---------------------------------------------------------------------------
58+
// Ghost row rendering in TaskList
59+
// ---------------------------------------------------------------------------
60+
61+
describe("Ghost row rendering in TaskList", () => {
62+
it("TaskList accepts a ghostTasks prop", () => {
63+
expect(taskListSrc).toMatch(/ghostTasks/);
64+
});
65+
66+
it("renders ghost rows with visually distinct styling", () => {
67+
// Ghost rows should have reduced opacity or dashed border
68+
expect(taskListSrc).toMatch(/opacity|dashed|ghost/i);
69+
});
70+
71+
it("renders an EXAMPLE badge on ghost rows", () => {
72+
expect(taskListSrc).toMatch(/EXAMPLE/);
73+
});
74+
75+
it("ghost rows are not clickable / have no interactive behavior", () => {
76+
// Ghost rows should use pointer-events-none or not have onClick
77+
expect(taskListSrc).toMatch(/pointer-events-none|GhostRow/);
78+
});
79+
});
80+
81+
// ---------------------------------------------------------------------------
82+
// BoardWorkspace integration
83+
// ---------------------------------------------------------------------------
84+
85+
describe("BoardWorkspace ghost task integration", () => {
86+
it("imports getGhostTasks from the lib module", () => {
87+
expect(boardWorkspaceSrc).toMatch(/getGhostTasks/);
88+
});
89+
90+
it("passes ghost tasks to TaskList when tasks < 3", () => {
91+
expect(boardWorkspaceSrc).toMatch(/ghostTasks/);
92+
});
93+
94+
it("has value-oriented encouragement copy instead of process instruction", () => {
95+
// Should NOT have the old process-oriented copy
96+
expect(boardWorkspaceSrc).not.toContain("Create tasks from chat or actions");
97+
// Should have value-oriented copy
98+
expect(boardWorkspaceSrc).toMatch(/follow-up|tasks appear here|review and action/i);
99+
});
100+
101+
it("no longer references 'Create tasks from chat or actions'", () => {
102+
expect(boardWorkspaceSrc).not.toContain("Create tasks from chat or actions");
103+
});
104+
});
105+
106+
// ---------------------------------------------------------------------------
107+
// Ghost task logic unit tests
108+
// ---------------------------------------------------------------------------
109+
110+
describe("getGhostTasks function behavior", () => {
111+
let getGhostTasks: (count: number) => Array<{ taskId: string; title: string; status: string }>;
112+
let GHOST_TASK_PREFIX: string;
113+
114+
beforeAll(async () => {
115+
const mod = await import(
116+
resolve(
117+
__dirname,
118+
"../../apps/desktop/src/features/board/lib/ghost-tasks.ts",
119+
)
120+
);
121+
getGhostTasks = mod.getGhostTasks;
122+
GHOST_TASK_PREFIX = mod.GHOST_TASK_PREFIX;
123+
});
124+
125+
it("returns 3 ghost tasks when 0 real tasks", () => {
126+
expect(getGhostTasks(0)).toHaveLength(3);
127+
});
128+
129+
it("returns 2 ghost tasks when 1 real task", () => {
130+
expect(getGhostTasks(1)).toHaveLength(2);
131+
});
132+
133+
it("returns 1 ghost task when 2 real tasks", () => {
134+
expect(getGhostTasks(2)).toHaveLength(1);
135+
});
136+
137+
it("returns 0 ghost tasks when 3+ real tasks", () => {
138+
expect(getGhostTasks(3)).toHaveLength(0);
139+
expect(getGhostTasks(10)).toHaveLength(0);
140+
});
141+
142+
it("all ghost task IDs start with the ghost prefix", () => {
143+
const ghosts = getGhostTasks(0);
144+
for (const t of ghosts) {
145+
expect(t.taskId).toMatch(new RegExp(`^${GHOST_TASK_PREFIX}`));
146+
}
147+
});
148+
149+
it("ghost tasks have unique IDs", () => {
150+
const ghosts = getGhostTasks(0);
151+
const ids = ghosts.map((t) => t.taskId);
152+
expect(new Set(ids).size).toBe(ids.length);
153+
});
154+
});

0 commit comments

Comments
 (0)