From e46e92362efa952b8c3ebf94962b65254a97f604 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Mon, 23 Feb 2026 17:20:52 -0600 Subject: [PATCH] Signed-off-by: OpenClaw Bot --- app/api/users/directory/route.ts | 29 ++++ hooks/use-activity-feed.ts | 146 ++++++++++++++++++-- stores/useTaskStore.ts | 229 ++++++++++++++----------------- 3 files changed, 264 insertions(+), 140 deletions(-) create mode 100644 app/api/users/directory/route.ts diff --git a/app/api/users/directory/route.ts b/app/api/users/directory/route.ts new file mode 100644 index 0000000..73315a3 --- /dev/null +++ b/app/api/users/directory/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { getServiceSupabase } from "@/lib/supabase/client"; + +type UserDirectoryRow = { + id: string; + name: string | null; + avatar_url: string | null; +}; + +export async function GET() { + try { + const supabase = getServiceSupabase(); + const { data, error } = await supabase + .from("users") + .select("id, name, avatar_url") + .order("name", { ascending: true }); + + if (error) { + console.error("[users/directory] Supabase query failed", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ users: (data ?? []) as UserDirectoryRow[] }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch user directory"; + console.error("[users/directory] Request failed", error); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/hooks/use-activity-feed.ts b/hooks/use-activity-feed.ts index 6c066f2..e72ec6d 100644 --- a/hooks/use-activity-feed.ts +++ b/hooks/use-activity-feed.ts @@ -32,20 +32,38 @@ interface NormalizedTaskComment { replies: NormalizedTaskComment[]; } +interface UserDirectoryRow { + id: string; + name: string | null; + avatar_url: string | null; +} + +function normalizeUserId(value: string | null | undefined): string | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + function resolveUserName( userId: string | null, users: UserDirectory, fallbackName?: string | null ): string { - if (userId && users[userId]?.name) return users[userId].name; + const normalizedUserId = normalizeUserId(userId); + if (normalizedUserId && users[normalizedUserId]?.name) return users[normalizedUserId].name; if (fallbackName && fallbackName.trim().length > 0) return fallbackName; if (!userId) return 'Unknown'; - return 'Unknown'; + + const normalizedId = userId.trim().toLowerCase(); + if (normalizedId === "assistant") return "Assistant"; + if (normalizedId === "user" || normalizedId === "legacy-user") return "User"; + return "User"; } function resolveUserAvatarUrl(userId: string | null, users: UserDirectory): string | undefined { - if (!userId) return undefined; - return users[userId]?.avatarUrl; + const normalizedUserId = normalizeUserId(userId); + if (!normalizedUserId) return undefined; + return users[normalizedUserId]?.avatarUrl; } function isValidTimestamp(value: string | null | undefined): value is string { @@ -112,7 +130,8 @@ function buildUserDirectory(rows: Array<{ id: string; name: string | null; avata const directory: UserDirectory = {}; rows.forEach((row) => { - const id = toNonEmptyString(row.id); + const rawId = toNonEmptyString(row.id); + const id = normalizeUserId(rawId); const name = toNonEmptyString(row.name); if (!id || !name) return; @@ -127,6 +146,7 @@ function buildUserDirectory(rows: Array<{ id: string; name: string | null; avata export function useActivityFeed(options: UseActivityFeedOptions = {}) { const { limit = 50, projectId, filterType = 'all' } = options; + const isDebug = process.env.NODE_ENV !== "production"; const [activities, setActivities] = useState([]); const [projects, setProjects] = useState([]); @@ -152,13 +172,38 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { setError(null); try { - const { data: userRows, error: usersError } = await supabaseClient - .from("users") - .select("id, name, avatar_url"); + let userRows: UserDirectoryRow[] = []; + try { + const response = await fetch("/api/users/directory", { cache: "no-store" }); + if (response.ok) { + const payload = (await response.json()) as { users?: UserDirectoryRow[] }; + userRows = Array.isArray(payload.users) ? payload.users : []; + } else { + if (isDebug) { + console.warn("[activity-feed] users directory API returned non-OK status", { status: response.status }); + } + } + } catch (error) { + if (isDebug) { + console.warn("[activity-feed] users directory API request failed, falling back to client query", error); + } + } - if (usersError) throw usersError; + if (userRows.length === 0) { + const { data: fallbackUserRows, error: usersError } = await supabaseClient + .from("users") + .select("id, name, avatar_url"); + if (usersError) throw usersError; + userRows = (fallbackUserRows || []) as UserDirectoryRow[]; + } - const userDirectory = buildUserDirectory(userRows || []); + const userDirectory = buildUserDirectory(userRows); + if (isDebug) { + console.log("[activity-feed] user directory loaded", { + userCount: Object.keys(userDirectory).length, + sampleUserIds: Object.keys(userDirectory).slice(0, 5), + }); + } const runTasksQuery = async (selectClause: string) => { let query = supabaseClient @@ -219,14 +264,62 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { assignee?: { id?: string; name?: string | null } | null; }>; + const globalTaskUserNameById: Record = {}; + typedTasks.forEach((task) => { + const createdById = normalizeUserId(task.created_by_id); + const updatedById = normalizeUserId(task.updated_by_id); + const assigneeId = normalizeUserId(task.assignee_id); + + if (createdById && task.created_by_name) globalTaskUserNameById[createdById] = task.created_by_name; + if (updatedById && task.updated_by_name) globalTaskUserNameById[updatedById] = task.updated_by_name; + if (assigneeId && (task.assignee_name || task.assignee?.name)) { + globalTaskUserNameById[assigneeId] = task.assignee_name || task.assignee?.name || ""; + } + }); + typedTasks.forEach((task) => { const project = task.projects; + const taskUserNameById: Record = {}; + + const createdById = normalizeUserId(task.created_by_id); + const updatedById = normalizeUserId(task.updated_by_id); + const assigneeId = normalizeUserId(task.assignee_id); + + if (createdById && task.created_by_name) { + taskUserNameById[createdById] = task.created_by_name; + } + if (updatedById && task.updated_by_name) { + taskUserNameById[updatedById] = task.updated_by_name; + } + if (assigneeId && (task.assignee_name || task.assignee?.name)) { + taskUserNameById[assigneeId] = task.assignee_name || task.assignee?.name || ""; + } - const createdByName = resolveUserName(task.created_by_id, userDirectory, task.created_by_name); - const updatedByName = resolveUserName(task.updated_by_id, userDirectory, task.updated_by_name); - const assigneeName = resolveUserName(task.assignee_id, userDirectory, task.assignee_name || task.assignee?.name || null); + const createdByName = resolveUserName( + task.created_by_id, + userDirectory, + task.created_by_name || (createdById ? globalTaskUserNameById[createdById] : null) || null + ); + const updatedByName = resolveUserName( + task.updated_by_id, + userDirectory, + task.updated_by_name || (updatedById ? globalTaskUserNameById[updatedById] : null) || null + ); + const assigneeName = resolveUserName( + task.assignee_id, + userDirectory, + task.assignee_name || task.assignee?.name || (assigneeId ? globalTaskUserNameById[assigneeId] : null) || null + ); const createdByAvatarUrl = resolveUserAvatarUrl(task.created_by_id, userDirectory); const updatedByAvatarUrl = resolveUserAvatarUrl(task.updated_by_id, userDirectory); + if (isDebug && (!task.created_by_id || createdByName === "User")) { + console.log("[activity-feed] created_by fallback", { + taskId: task.id, + createdById: task.created_by_id, + createdByNameFromTask: task.created_by_name, + resolvedName: createdByName, + }); + } // Task creation activity if (filterType === 'all' || filterType === 'task_created') { @@ -307,8 +400,25 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { flattenedComments.forEach(({ comment, path }) => { const commentAuthorId = comment.commentAuthorId; - const commentAuthorName = resolveUserName(commentAuthorId, userDirectory); + const normalizedCommentAuthorId = normalizeUserId(commentAuthorId); + const commentAuthorName = resolveUserName( + commentAuthorId, + userDirectory, + (normalizedCommentAuthorId + ? taskUserNameById[normalizedCommentAuthorId] || globalTaskUserNameById[normalizedCommentAuthorId] + : null) ?? null + ); const commentAuthorAvatarUrl = resolveUserAvatarUrl(commentAuthorId, userDirectory); + if (isDebug && (commentAuthorName === "User" || commentAuthorName === "Unknown")) { + console.log("[activity-feed] comment author fallback", { + taskId: task.id, + commentId: comment.id, + path, + commentAuthorId, + taskFallbackName: taskUserNameById[commentAuthorId] ?? null, + resolvedName: commentAuthorName, + }); + } activityItems.push({ id: `${task.id}-comment-${comment.id}-${path}`, @@ -335,6 +445,14 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { // Apply limit after all activities are collected setActivities(activityItems.slice(0, limit)); + if (isDebug) { + console.log("[activity-feed] activities prepared", { + totalActivities: activityItems.length, + returnedActivities: Math.min(activityItems.length, limit), + filterType, + projectId: projectId ?? null, + }); + } } catch (err) { console.error('Error fetching activities:', err); setError(err instanceof Error ? err.message : 'Failed to fetch activities'); diff --git a/stores/useTaskStore.ts b/stores/useTaskStore.ts index a5beb3b..1e314cd 100644 --- a/stores/useTaskStore.ts +++ b/stores/useTaskStore.ts @@ -21,18 +21,10 @@ export interface Comment { id: string text: string createdAt: string - author: CommentAuthor | 'user' | 'assistant' + commentAuthorId: string replies?: Comment[] } -export interface CommentAuthor { - id: string - name: string - email?: string - avatarUrl?: string - type: 'human' | 'assistant' -} - export interface UserProfile { id: string name: string @@ -120,7 +112,7 @@ interface TaskStore { getTasksBySprint: (sprintId: string) => Task[] // Comment actions - addComment: (taskId: string, text: string, author?: CommentAuthor | 'user' | 'assistant') => void + addComment: (taskId: string, text: string, commentAuthorId: string) => void deleteComment: (taskId: string, commentId: string) => void // Filters @@ -165,8 +157,8 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c1', text: 'Need 1-to-many notes, not one big text field', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c2', text: 'Agreed - will rebuild with proper comment threads', createdAt: new Date().toISOString(), author: 'assistant' }, + { id: 'c1', text: 'Need 1-to-many notes, not one big text field', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c2', text: 'Agreed - will rebuild with proper comment threads', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, ], tags: ['ui', 'rewrite'] }, @@ -195,11 +187,11 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c3', text: 'User has local Gitea at http://192.168.1.128:3000', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c4', text: 'Options: 1) Create dedicated bot account (recommended), 2) Use existing account', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c5', text: 'Account created: mbruce@topdoglabs.com / !7883Gitea (username: ai-agent)', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c6', text: 'Git configured for all 3 projects. Gitea remotes added.', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c7', text: '✅ All 3 repos created and pushed to Gitea: gantt-board, blog-backup, heartbeat-monitor', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c3', text: 'User has local Gitea at http://192.168.1.128:3000', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c4', text: 'Options: 1) Create dedicated bot account (recommended), 2) Use existing account', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c5', text: 'Account created: mbruce@topdoglabs.com / !7883Gitea (username: ai-agent)', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c6', text: 'Git configured for all 3 projects. Gitea remotes added.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c7', text: '✅ All 3 repos created and pushed to Gitea: gantt-board, blog-backup, heartbeat-monitor', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['gitea', 'git', 'automation', 'infrastructure'] }, @@ -215,9 +207,9 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c8', text: 'Reference: https://uptimerobot.com - study their homepage, dashboard, and status page designs', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c9', text: 'Focus on: clean modern UI, blue/green color scheme, card-based layouts, uptime percentage displays, incident timelines', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c29', text: 'COMPLETED: Full rebuild with Next.js + shadcn/ui + Framer Motion. Dark OLED theme, glass-morphism cards, animated status indicators, sparkline visualizations, grid/list views, tooltips, progress bars. Production-grade at http://localhost:3005', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c8', text: 'Reference: https://uptimerobot.com - study their homepage, dashboard, and status page designs', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c9', text: 'Focus on: clean modern UI, blue/green color scheme, card-based layouts, uptime percentage displays, incident timelines', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c29', text: 'COMPLETED: Full rebuild with Next.js + shadcn/ui + Framer Motion. Dark OLED theme, glass-morphism cards, animated status indicators, sparkline visualizations, grid/list views, tooltips, progress bars. Production-grade at http://localhost:3005', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['ui', 'ux', 'redesign', 'dashboard', 'monitoring'] }, @@ -233,12 +225,12 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c21', text: 'Issue: Task status changes require hard refresh to see', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c22', text: 'Current: localStorage persistence via Zustand', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c23', text: 'Need: Server-side storage or sync on regular refresh', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c44', text: 'COMPLETED: Added /api/tasks endpoint with file-based storage', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c45', text: 'COMPLETED: Store now syncs from server on load and auto-syncs changes', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c46', text: 'COMPLETED: Falls back to localStorage if server unavailable', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c21', text: 'Issue: Task status changes require hard refresh to see', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c22', text: 'Current: localStorage persistence via Zustand', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c23', text: 'Need: Server-side storage or sync on regular refresh', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c44', text: 'COMPLETED: Added /api/tasks endpoint with file-based storage', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c45', text: 'COMPLETED: Store now syncs from server on load and auto-syncs changes', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c46', text: 'COMPLETED: Falls back to localStorage if server unavailable', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['ui', 'sync', 'localstorage', 'real-time'] }, @@ -254,11 +246,11 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c10', text: 'Blog should show: [headline](url) as clickable markdown links', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c11', text: 'Telegram gets summary + "Full digest at: http://localhost:3003"', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c41', text: 'COMPLETED: Fixed parseDigest to extract URLs from markdown links [Title](url) in title lines', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c42', text: 'COMPLETED: Title is now the clickable link with external link icon on hover', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c43', text: 'COMPLETED: Better hover states - title turns blue, external link icon appears', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c10', text: 'Blog should show: [headline](url) as clickable markdown links', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c11', text: 'Telegram gets summary + "Full digest at: http://localhost:3003"', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c41', text: 'COMPLETED: Fixed parseDigest to extract URLs from markdown links [Title](url) in title lines', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c42', text: 'COMPLETED: Title is now the clickable link with external link icon on hover', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c43', text: 'COMPLETED: Better hover states - title turns blue, external link icon appears', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['blog', 'ui', 'markdown', 'links'] }, @@ -274,12 +266,12 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c12', text: 'Issue: Cron job exists but sites are still going down without restart', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c13', text: 'Need to verify: cron is running, checks all 3 ports, restart logic works, permissions correct', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c14', text: 'ALL SITES BACK UP - manually restarted at 14:19. Now investigating why auto-restart failed.', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c15', text: 'Problem: Port 3005 was still in use (EADDRINUSE), need better process cleanup in restart logic', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c16', text: 'FIXED: Updated cron job with pkill cleanup before restart + 2s delay. Created backup script: monitor-restart.sh', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c17', text: 'All 3 sites stable. Cron job now properly kills old processes before restarting to avoid port conflicts.', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c12', text: 'Issue: Cron job exists but sites are still going down without restart', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c13', text: 'Need to verify: cron is running, checks all 3 ports, restart logic works, permissions correct', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c14', text: 'ALL SITES BACK UP - manually restarted at 14:19. Now investigating why auto-restart failed.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c15', text: 'Problem: Port 3005 was still in use (EADDRINUSE), need better process cleanup in restart logic', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c16', text: 'FIXED: Updated cron job with pkill cleanup before restart + 2s delay. Created backup script: monitor-restart.sh', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c17', text: 'All 3 sites stable. Cron job now properly kills old processes before restarting to avoid port conflicts.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['monitoring', 'cron', 'bug', 'infrastructure', 'urgent'] }, @@ -295,10 +287,10 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c18', text: 'Problem: Sites go down randomly - what is killing them?', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c19', text: 'Suspects: Memory leaks, file watcher hitting limits, SSH session timeout, macOS power nap, OOM killer', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c20', text: 'Need to add logging/capture to see what kills processes', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c27', text: 'COMPLETED: Root cause analysis done. Primary suspect: Next.js dev server memory leaks. Secondary: SSH timeout, OOM killer, power mgmt. Full report: root-cause-analysis.md. Monitoring script deployed.', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c18', text: 'Problem: Sites go down randomly - what is killing them?', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c19', text: 'Suspects: Memory leaks, file watcher hitting limits, SSH session timeout, macOS power nap, OOM killer', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c20', text: 'Need to add logging/capture to see what kills processes', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c27', text: 'COMPLETED: Root cause analysis done. Primary suspect: Next.js dev server memory leaks. Secondary: SSH timeout, OOM killer, power mgmt. Full report: root-cause-analysis.md. Monitoring script deployed.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['debugging', 'research', 'infrastructure', 'root-cause'] }, @@ -314,9 +306,9 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c24', text: 'User cannot currently change priority from Medium to High in UI', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c25', text: 'Need priority dropdown/editor in task detail view', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c28', text: 'COMPLETED: Added priority buttons to task detail dialog. Click any task to see Low/Medium/High/Urgent buttons with color coding. Changes apply immediately.', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c24', text: 'User cannot currently change priority from Medium to High in UI', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c25', text: 'Need priority dropdown/editor in task detail view', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c28', text: 'COMPLETED: Added priority buttons to task detail dialog. Click any task to see Low/Medium/High/Urgent buttons with color coding. Changes apply immediately.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['ui', 'kanban', 'feature', 'priority'] }, @@ -332,19 +324,19 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c29', text: 'PROBLEM: User needs to share screenshots of local websites with friends who cannot access home network. Browser tool unavailable (Chrome extension not connected).', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c30', text: 'INVESTIGATE: macOS native screenshot capabilities - screencapture CLI, Automator workflows, AppleScript', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c31', text: 'INVESTIGATE: Alternative browser automation - Playwright, Selenium, WebDriver without Chrome extension requirement', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c32', text: 'INVESTIGATE: OpenClaw Gateway configuration - browser profiles, node setup, gateway settings', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c33', text: 'INVESTIGATE: Third-party screenshot APIs or services that could work locally', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c34', text: 'DELIVERABLE: Document ALL options found with pros/cons, setup requirements, and recommendation for best solution', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c35', text: 'FINDING: /usr/sbin/screencapture exists but requires interactive mode or captures full screen - cannot target specific URL', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c36', text: 'FINDING: Google Chrome is installed at /Applications/Google Chrome.app - Playwright can use this for headless screenshots', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c37', text: 'FINDING: Playwright v1.58.2 is already available via npx - can automate Chrome without extension', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c38', text: 'FINDING: OpenClaw Gateway is running on port 18789 with browser profile "chrome" but extension not attached to any tab', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c39', text: 'SUCCESS: Playwright + Google Chrome works! Successfully captured screenshot of http://localhost:3005 using headless Chrome', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c40', text: 'RECOMMENDATION: Install Playwright globally or in workspace so screenshots work without temporary installs. Command: npm install -g playwright', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c47', text: 'COMPLETED: Playwright installed globally. Screenshots now work anytime without setup.', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c29', text: 'PROBLEM: User needs to share screenshots of local websites with friends who cannot access home network. Browser tool unavailable (Chrome extension not connected).', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c30', text: 'INVESTIGATE: macOS native screenshot capabilities - screencapture CLI, Automator workflows, AppleScript', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c31', text: 'INVESTIGATE: Alternative browser automation - Playwright, Selenium, WebDriver without Chrome extension requirement', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c32', text: 'INVESTIGATE: OpenClaw Gateway configuration - browser profiles, node setup, gateway settings', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c33', text: 'INVESTIGATE: Third-party screenshot APIs or services that could work locally', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c34', text: 'DELIVERABLE: Document ALL options found with pros/cons, setup requirements, and recommendation for best solution', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c35', text: 'FINDING: /usr/sbin/screencapture exists but requires interactive mode or captures full screen - cannot target specific URL', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c36', text: 'FINDING: Google Chrome is installed at /Applications/Google Chrome.app - Playwright can use this for headless screenshots', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c37', text: 'FINDING: Playwright v1.58.2 is already available via npx - can automate Chrome without extension', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c38', text: 'FINDING: OpenClaw Gateway is running on port 18789 with browser profile "chrome" but extension not attached to any tab', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c39', text: 'SUCCESS: Playwright + Google Chrome works! Successfully captured screenshot of http://localhost:3005 using headless Chrome', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c40', text: 'RECOMMENDATION: Install Playwright globally or in workspace so screenshots work without temporary installs. Command: npm install -g playwright', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c47', text: 'COMPLETED: Playwright installed globally. Screenshots now work anytime without setup.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['research', 'screenshot', 'macos', 'openclaw', 'investigation'] }, @@ -360,13 +352,13 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c48', text: 'Focus: iOS apps with MRR potential (subscriptions, recurring revenue)', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c49', text: 'Requirements: Well-thought-out, multi-screen, viral potential', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c50', text: 'Target: $1K-$10K+ MRR opportunities', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c51', text: 'Research areas: App Store trends, indie success stories, underserved niches', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c52', text: 'COMPLETED: Full research report saved to memory/ios-mrr-opportunities.md', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c53', text: 'TOP 10 IDEAS: (1) AI Translator Keyboard - $15K/mo potential, (2) Finance Widget Suite, (3) Focus Timer with Live Activities - RECOMMENDED, (4) AI Photo Enhancer, (5) Habit Tracker with Social, (6) Local Business Review Widget, (7) Audio Journal with Voice-to-Text, (8) Plant Care Tracker, (9) Sleep Sounds with HomeKit, (10) Family Password Manager', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c54', text: 'RECOMMENDATION: Focus Timer with Live Activities (#3) - Best first project. Technically achievable, proven market, high viral potential through focus streak sharing, leverages iOS 16+ features.', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c48', text: 'Focus: iOS apps with MRR potential (subscriptions, recurring revenue)', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c49', text: 'Requirements: Well-thought-out, multi-screen, viral potential', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c50', text: 'Target: $1K-$10K+ MRR opportunities', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c51', text: 'Research areas: App Store trends, indie success stories, underserved niches', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c52', text: 'COMPLETED: Full research report saved to memory/ios-mrr-opportunities.md', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c53', text: 'TOP 10 IDEAS: (1) AI Translator Keyboard - $15K/mo potential, (2) Finance Widget Suite, (3) Focus Timer with Live Activities - RECOMMENDED, (4) AI Photo Enhancer, (5) Habit Tracker with Social, (6) Local Business Review Widget, (7) Audio Journal with Voice-to-Text, (8) Plant Care Tracker, (9) Sleep Sounds with HomeKit, (10) Family Password Manager', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c54', text: 'RECOMMENDATION: Focus Timer with Live Activities (#3) - Best first project. Technically achievable, proven market, high viral potential through focus streak sharing, leverages iOS 16+ features.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['ios', 'mrr', 'research', 'side-project', 'entrepreneurship', 'app-ideas'] }, @@ -382,13 +374,13 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c55', text: 'Issue: Blog shows raw markdown [text](url) instead of clickable links', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c56', text: 'Solution: Install react-markdown and render content as HTML', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c57', text: 'Expected: Properly formatted markdown with clickable links, headers, lists', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c58', text: 'COMPLETED: Installed react-markdown and remark-gfm', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c59', text: 'COMPLETED: Installed @tailwindcss/typography for prose styling', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c60', text: 'COMPLETED: Updated page.tsx to render markdown as HTML with clickable links', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c61', text: 'COMPLETED: Links now open in new tab with blue styling and hover effects', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c55', text: 'Issue: Blog shows raw markdown [text](url) instead of clickable links', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c56', text: 'Solution: Install react-markdown and render content as HTML', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c57', text: 'Expected: Properly formatted markdown with clickable links, headers, lists', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c58', text: 'COMPLETED: Installed react-markdown and remark-gfm', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c59', text: 'COMPLETED: Installed @tailwindcss/typography for prose styling', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c60', text: 'COMPLETED: Updated page.tsx to render markdown as HTML with clickable links', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c61', text: 'COMPLETED: Links now open in new tab with blue styling and hover effects', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['blog', 'ui', 'markdown', 'frontend'] }, @@ -404,10 +396,10 @@ const defaultTasks: Task[] = [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), comments: [ - { id: 'c62', text: 'Goal: Convert daily digest text to audio for dog walks', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c63', text: 'Requirement: Free or very low cost solution', createdAt: new Date().toISOString(), author: 'user' }, - { id: 'c64', text: 'Look into: ElevenLabs free tier, Google TTS, AWS Polly, Piper, Coqui TTS', createdAt: new Date().toISOString(), author: 'assistant' }, - { id: 'c65', text: 'Also research: RSS feed generation, podcast hosting options', createdAt: new Date().toISOString(), author: 'assistant' } + { id: 'c62', text: 'Goal: Convert daily digest text to audio for dog walks', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c63', text: 'Requirement: Free or very low cost solution', createdAt: new Date().toISOString(), commentAuthorId: 'user' }, + { id: 'c64', text: 'Look into: ElevenLabs free tier, Google TTS, AWS Polly, Piper, Coqui TTS', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }, + { id: 'c65', text: 'Also research: RSS feed generation, podcast hosting options', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' } ], tags: ['research', 'tts', 'podcast', 'audio', 'digest', 'accessibility'] } @@ -430,57 +422,15 @@ const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCur return { id, name, email, avatarUrl } } -const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({ +const profileToCommentAuthor = (profile: UserProfile) => ({ id: profile.id, name: profile.name, email: profile.email, avatarUrl: profile.avatarUrl, - type: 'human', }) -const assistantAuthor: CommentAuthor = { - id: 'assistant', - name: 'Assistant', - type: 'assistant', -} - -const normalizeCommentAuthor = (value: unknown): CommentAuthor => { - if (value === 'assistant') return assistantAuthor - if (value === 'user') { - return { - id: 'legacy-user', - name: 'User', - type: 'human', - } - } - - if (!value || typeof value !== 'object') { - return { - id: 'legacy-user', - name: 'User', - type: 'human', - } - } - - const candidate = value as Partial - const type: CommentAuthor['type'] = - candidate.type === 'assistant' || candidate.id === 'assistant' ? 'assistant' : 'human' - - const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0 - ? candidate.id - : type === 'assistant' - ? 'assistant' - : 'legacy-user' - const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0 - ? candidate.name.trim() - : type === 'assistant' - ? 'Assistant' - : 'User' - const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined - const avatarUrl = typeof candidate.avatarUrl === 'string' && candidate.avatarUrl.trim().length > 0 ? candidate.avatarUrl : undefined - - return { id, name, email, avatarUrl, type } -} +const normalizeCommentAuthorId = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null const normalizeComments = (value: unknown): Comment[] => { if (!Array.isArray(value)) return [] @@ -490,13 +440,15 @@ const normalizeComments = (value: unknown): Comment[] => { for (const entry of value) { if (!entry || typeof entry !== 'object') continue const candidate = entry as Partial - if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') continue + if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string' || typeof candidate.createdAt !== 'string') continue + const commentAuthorId = normalizeCommentAuthorId(candidate.commentAuthorId) + if (!commentAuthorId) continue comments.push({ id: candidate.id, text: candidate.text, - createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(), - author: normalizeCommentAuthor(candidate.author), + createdAt: candidate.createdAt, + commentAuthorId, replies: normalizeComments(candidate.replies), }) } @@ -787,13 +739,14 @@ export const useTaskStore = create()( return get().tasks.filter((t) => t.sprintId === sprintId) }, - addComment: (taskId, text, author) => { - const actor = normalizeCommentAuthor(author ?? profileToCommentAuthor(get().currentUser)) + addComment: (taskId, text, commentAuthorId) => { + const normalizedCommentAuthorId = normalizeCommentAuthorId(commentAuthorId) + if (!normalizedCommentAuthorId) return const newComment: Comment = { id: Date.now().toString(), text, createdAt: new Date().toISOString(), - author: actor, + commentAuthorId: normalizedCommentAuthorId, replies: [], } set((state) => { @@ -849,6 +802,30 @@ export const useTaskStore = create()( }), { name: 'task-store', + version: 2, + migrate: (persistedState) => { + if (!persistedState || typeof persistedState !== 'object') { + return {} + } + + const state = persistedState as { + currentUser?: unknown + selectedProjectId?: unknown + selectedTaskId?: unknown + selectedSprintId?: unknown + } + + const normalizedCurrentUser = normalizeUserProfile(state.currentUser, defaultCurrentUser) + const shouldReplaceLegacyUnknown = + normalizedCurrentUser.name.trim().toLowerCase() === 'unknown user' || normalizedCurrentUser.id.trim().length === 0 + + return { + currentUser: shouldReplaceLegacyUnknown ? createLocalUserProfile() : normalizedCurrentUser, + selectedProjectId: typeof state.selectedProjectId === 'string' ? state.selectedProjectId : null, + selectedTaskId: typeof state.selectedTaskId === 'string' ? state.selectedTaskId : null, + selectedSprintId: typeof state.selectedSprintId === 'string' ? state.selectedSprintId : null, + } + }, partialize: (state) => ({ // Persist user identity and UI state, not task data currentUser: state.currentUser,