Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion apps/web/app/dashboard/projects/[projectId]/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"

import { Status } from "@/generated/prisma/enums"
import { createAuditLog, formatChangedFields } from "@/lib/api/audit-log"
import { findReviewReader } from "@/lib/api/review-reader"
import { invalidateProjectAndCompanyCache } from "@/lib/api/cache"
import {
projectAgentSelect,
Expand All @@ -16,6 +17,7 @@ import {
import { getTaskFreshnessUpdate } from "@/lib/api/task-freshness"
import { userTaskUpdater } from "@/lib/api/task-updater"
import { getSession } from "@/lib/auth"
import { getDoneSummaryReviewReadMarkerWhere } from "@/lib/dashboard/note-read-state"
import { prisma } from "@/lib/prisma"

import type { ProjectDetailData, ProjectTaskDetail } from "./types"
Expand Down Expand Up @@ -248,6 +250,65 @@ export async function updateTaskStatusAction(
return updateTaskAction(taskId, { status } as TaskMutationPayload)
}

export async function markProjectTaskSummaryReadAction(
taskId: string
): Promise<ActionResult<{ taskId: string; readBy: string }>> {
const session = await getSession()

if (!session) {
return { ok: false, error: "Unauthorized" }
}

const task = await prisma.task.findFirst({
where: {
id: taskId,
status: Status.done,
archivedAt: null,
note: { not: null },
project: { company: { userId: session.userId } },
},
select: {
id: true,
projectId: true,
project: { select: { companyId: true } },
},
})

if (!task) {
return { ok: false, error: "Done task note not found." }
}

const reviewReader = await findReviewReader(task.project.companyId)

if (!reviewReader) {
return { ok: false, error: "Review reader agent not found." }
}

const readAt = new Date()

await prisma.taskReadMarker.upsert({
where: {
taskId_agentId_status: {
taskId: task.id,
agentId: reviewReader.id,
status: Status.done,
},
},
create: {
taskId: task.id,
agentId: reviewReader.id,
status: Status.done,
readAt,
},
update: { readAt },
})

await invalidateAndRevalidate(task.project.companyId, task.projectId)
revalidatePath("/dashboard/notes")

return { ok: true, taskId: task.id, readBy: reviewReader.AgentId }
}

export async function updateTaskAction(
taskId: string,
payload: Partial<TaskMutationPayload>
Expand Down Expand Up @@ -581,6 +642,11 @@ async function loadProjectDetail(projectId: string, userId: string) {
}

export async function loadProjectTaskCards(projectId: string) {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { companyId: true },
})
const reviewReader = project ? await findReviewReader(project.companyId) : null
const tasks = await prisma.task.findMany({
where: { projectId, archivedAt: null },
orderBy: { name: "asc" },
Expand All @@ -596,7 +662,7 @@ export async function loadProjectTaskCards(projectId: string) {
status: true,
blockingReason: true,
readMarkers: {
where: { status: Status.done, agent: { AgentId: "main" } },
where: getDoneSummaryReviewReadMarkerWhere(reviewReader),
select: { readAt: true },
},
assigned: { select: { id: true, name: true, position: true } },
Expand Down
55 changes: 53 additions & 2 deletions apps/web/app/dashboard/projects/[projectId]/task-kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
deleteTaskAction,
getProjectDetailAction,
getTaskDetailAction,
markProjectTaskSummaryReadAction,
updateTaskAction,
updateTaskStatusAction,
} from "./actions"
Expand Down Expand Up @@ -235,6 +236,17 @@ export function TaskKanban({
queryClient.invalidateQueries({ queryKey: ["agents", companyId] })
},
})
const markSummaryReadMutation = useMutation({
mutationFn: (taskId: string) =>
unwrapActionResult(markProjectTaskSummaryReadAction(taskId)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["project", projectId] })
queryClient.invalidateQueries({
queryKey: ["dashboard-summary", companyId],
})
},
})

const deleteMutation = useMutation({
mutationFn: (taskId: string) =>
unwrapActionResult(deleteTaskAction(taskId)),
Expand Down Expand Up @@ -430,6 +442,15 @@ export function TaskKanban({
label="Done summary"
text={task.notePreview}
tone={task.isUnreadDoneSummary ? "unread" : "default"}
summaryUpdatedAt={task.summaryUpdatedAt}
isUnread={task.isUnreadDoneSummary}
isMarkingRead={
markSummaryReadMutation.isPending &&
markSummaryReadMutation.variables === task.id
}
onMarkRead={() =>
markSummaryReadMutation.mutate(task.id)
}
/>
) : task.summaryUpdatedAt ? (
<p className="rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">
Expand Down Expand Up @@ -772,16 +793,25 @@ function LatencyDiagnostics({
}

function TaskCardPreview({
isMarkingRead = false,
isUnread = false,
label,
onMarkRead,
summaryUpdatedAt,
text,
tone = "default",
}: {
isMarkingRead?: boolean
isUnread?: boolean
label: string
onMarkRead?: () => void
summaryUpdatedAt?: string | Date | null
text: string
tone?: "default" | "unread"
}) {
const [expanded, setExpanded] = useState(false)
const compact = text.length > 120
const summaryDate = summaryUpdatedAt ? new Date(summaryUpdatedAt) : null
const hasSummaryDate = summaryDate && !Number.isNaN(summaryDate.getTime())

return (
<div
Expand All @@ -804,12 +834,17 @@ function TaskCardPreview({
>
<span className="text-xs tracking-[0.16em] uppercase">{label}</span>
<span className="flex shrink-0 items-center gap-1 text-xs">
{compact ? (expanded ? "Less" : "More") : "View"}
{expanded ? "Less" : "More"}
<ChevronDown
className={cn("size-4 transition-transform", expanded ? "rotate-180" : "")}
/>
</span>
</button>
{hasSummaryDate ? (
<p className="mt-1 text-xs opacity-80">
Updated {formatRelativeTime(summaryDate)}
</p>
) : null}
<p
className={cn(
"mt-2 whitespace-pre-wrap break-words",
Expand All @@ -818,6 +853,22 @@ function TaskCardPreview({
>
{text}
</p>
{isUnread && onMarkRead ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3 h-8 bg-background/80 text-xs"
disabled={isMarkingRead}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onMarkRead()
}}
>
{isMarkingRead ? "Marking..." : "Mark reviewed by main"}
</Button>
) : null}
</div>
)
}
Expand Down
8 changes: 7 additions & 1 deletion apps/web/lib/dashboard/note-read-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { isDoneSummaryUnread } from "./note-read-state"
import { getDoneSummaryReviewReadMarkerWhere, isDoneSummaryUnread } from "./note-read-state"

describe("dashboard done summary read state", () => {
it("treats noted done tasks with unknown summary freshness as unread", () => {
Expand Down Expand Up @@ -32,4 +32,10 @@ describe("dashboard done summary read state", () => {
false
)
})
it("scopes project-card done summary reads to the resolved review reader", () => {
assert.deepEqual(getDoneSummaryReviewReadMarkerWhere({ id: "agent-natsuki" }), {
status: "done",
agentId: "agent-natsuki",
})
})
})
19 changes: 19 additions & 0 deletions apps/web/lib/dashboard/note-read-state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { Status } from "@/generated/prisma/enums"

const missingReviewReaderAgentId = "__no_review_reader__"

type DoneSummaryReviewReader = {
id: string
} | null

export function isDoneSummaryUnread({
readAt,
summaryUpdatedAt,
Expand All @@ -7,3 +15,14 @@ export function isDoneSummaryUnread({
}) {
return !readAt || !summaryUpdatedAt || readAt < summaryUpdatedAt
}

export function getDoneSummaryReviewReadMarkerWhere(
reviewReader: DoneSummaryReviewReader
) {
return {
status: Status.done,
...(reviewReader
? { agentId: reviewReader.id }
: { agentId: missingReviewReaderAgentId }),
}
}
Loading