Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4e0ab6c
perf: trim task access check to slim gate columns
FrkAk Jun 9, 2026
6891886
perf: slim project-overview task projection, drop dead wrapper
FrkAk Jun 9, 2026
08fd734
perf: drop history/categories/createdAt from home-grid list query
FrkAk Jun 9, 2026
37b33f4
perf: drop history/createdAt from project access row
FrkAk Jun 9, 2026
dbaac8d
perf: request-memoize sidebar projects and user teams
FrkAk Jun 9, 2026
6d74ba1
perf: request-memoize workspace project chrome read
FrkAk Jun 9, 2026
81b47a2
perf: context-bundle endpoint reads task and traverses once
FrkAk Jun 9, 2026
77e636f
perf: depth-aware task fetch for MCP context builders
FrkAk Jun 9, 2026
f7da136
perf: seed task detail header from slim graph cache
FrkAk Jun 9, 2026
dea8fd9
chore: format
FrkAk Jun 9, 2026
9403f47
fix: guard pre-resolved project access row mismatch
FrkAk Jun 10, 2026
d06b429
refactor: type depth null casts, pin agent superset invariant
FrkAk Jun 10, 2026
b65e41c
test: reuse shared rich fixture in context bundle api test
FrkAk Jun 10, 2026
8e70278
docs: flag fabricated placeholder fields in workspace client
FrkAk Jun 10, 2026
9fa897e
fix: exclude nested worktrees from eslint scan
FrkAk Jun 10, 2026
933ce47
feat: staggered shimmer skeleton for task detail loading
FrkAk Jun 10, 2026
9627c5f
feat: defer detail skeleton so fast loads never flash it
FrkAk Jun 10, 2026
e9fc6de
docs: remove diff-relative phrasing from pr jsdocs
FrkAk Jun 10, 2026
b024a83
fix: reset skeleton visibility when the selected task changes
FrkAk Jun 10, 2026
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
35 changes: 24 additions & 11 deletions app/api/task/[taskId]/context/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { getAuthContext } from "@/lib/auth/context";
import { ForbiddenError } from "@/lib/auth/authorization";
import { getTaskFull } from "@/lib/data/task";
import { getTaskProjectId } from "@/lib/data/task";
import { getProjectMaxUpdatedAt } from "@/lib/data/project";
import { buildAgentContext } from "@/lib/context/_core/agent";
import { buildPlanningContext } from "@/lib/context/_core/planning";
import { resolveContextBundle } from "@/lib/context/_core/bundle";
import { buildAgentContextFrom } from "@/lib/context/_core/agent";
import { buildPlanningContextFrom } from "@/lib/context/_core/planning";
import {
buildWorkingContext,
buildWorkingContextFrom,
formatWorkingContext,
} from "@/lib/context/_core/working";
import { withUserContext } from "@/lib/db/rls";
import { conditionalRespond, etagMatches } from "@/lib/api/conditional";
import { internalError } from "@/lib/api/error";
import { error } from "@/lib/api/response";
Expand All @@ -19,6 +21,11 @@ import { error } from "@/lib/api/response";
* access first (a missing or cross-team task surfaces as 404) and the URL
* task id is the authoritative scope.
*
* The validator path reads only the slim `projectId` gate, so HEAD/304
* never pay for a full task. On a cache miss the task row and dependency
* traversal are resolved once via {@link resolveContextBundle} and fed to
* the three pure context cores.
*
* `Last-Modified` is the project-max validator (see
* {@link getProjectMaxUpdatedAt}) — over-conservative but trivial to
* compute, and bundle bytes are small enough that occasional false-positive
Expand All @@ -37,18 +44,24 @@ async function handle(req: Request, taskId: string): Promise<Response> {
}

try {
const task = await getTaskFull(ctx, taskId);
const max = await getProjectMaxUpdatedAt(ctx, task.projectId);
const projectId = await getTaskProjectId(ctx, taskId);
const max = await getProjectMaxUpdatedAt(ctx, projectId);

if (req.method === "HEAD" || etagMatches(req, max)) {
return conditionalRespond(req, null, max);
}

const [agent, planning, workingRaw] = await Promise.all([
buildAgentContext(ctx, taskId),
buildPlanningContext(ctx, taskId),
buildWorkingContext(ctx, taskId),
]);
const { agent, planning, workingRaw } = await withUserContext(
ctx.userId,
async (tx) => {
const bundle = await resolveContextBundle(tx, taskId);
return {
agent: buildAgentContextFrom(bundle),
planning: buildPlanningContextFrom(bundle),
workingRaw: buildWorkingContextFrom(bundle),
};
},
);
const working = await formatWorkingContext(workingRaw);
return conditionalRespond(req, { agent, planning, working }, max);
} catch (err) {
Expand Down
58 changes: 58 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,64 @@ html.light .progress-shimmer::after {
animation: loading-fade-in 220ms ease-out both;
}

/* ===========================================
SKELETON LOADING
===========================================
Placeholder bars for body content while a detail fetch resolves.
A single accent-tinted sheen sweeps each bar; `--skeleton-delay`
staggers both the entrance and the sweep so the wave travels down
the panel instead of twinkling at random. The sheen rests off-canvas
between passes, so the reduced-motion clamp freezes bars flat. */
:root {
--skeleton-base: rgba(255, 255, 255, 0.05);
--skeleton-sheen: rgba(165, 180, 252, 0.09);
}
html.light {
--skeleton-base: rgba(15, 17, 26, 0.06);
--skeleton-sheen: rgba(67, 56, 202, 0.08);
}

@keyframes skeleton-wave {
0% {
background-position: 135% 0;
}
55%,
100% {
background-position: -65% 0;
}
}

.skeleton-bar {
border-radius: var(--skeleton-radius, 4px);
background-color: var(--skeleton-base);
background-image: linear-gradient(
100deg,
transparent 36%,
var(--skeleton-sheen) 50%,
transparent 64%
);
background-repeat: no-repeat;
background-size: 220% 100%;
background-position: 135% 0;
animation: skeleton-wave 2.2s ease-in-out infinite;
animation-delay: var(--skeleton-delay, 0ms);
}

@keyframes rise-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.rise-in {
animation: rise-in 280ms ease-out both;
animation-delay: var(--skeleton-delay, 0ms);
}

/* ===========================================
REDUCED MOTION
=========================================== */
Expand Down
10 changes: 6 additions & 4 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { listProjectsSlim } from "@/lib/graph/queries";
import { listUserTeamsAction } from "@/lib/actions/team-list";
import {
loadSidebarProjects,
loadUserTeams,
} from "@/lib/server/request-loaders";
import { requireMembership } from "@/lib/auth/membership";
import { TopBar } from "@/components/layout/TopBar";
import { AppShell } from "@/components/layout/AppShell";
Expand All @@ -24,8 +26,8 @@ export default async function HomePage() {
await requireMembership();

const [projects, teamsResult] = await Promise.all([
listProjectsSlim(),
listUserTeamsAction(),
loadSidebarProjects(),
loadUserTeams(),
]);

const teams = teamsResult.ok ? teamsResult.data : [];
Expand Down
47 changes: 45 additions & 2 deletions app/project/[projectId]/_components/WorkspaceClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import { PropRail } from "@/components/workspace/detail/PropRail";
import { PropRailDrawer } from "@/components/workspace/detail/PropRailDrawer";
import { WorkspaceGraphView } from "@/components/workspace/graph/WorkspaceGraphView";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { useSkeletonVisibility } from "@/hooks/useSkeletonVisibility";
import { DeferredLoadingSpinner } from "@/components/shared/DeferredLoadingSpinner";
import { projectKeys, taskKeys } from "@/lib/query/keys";
import { fetchProjectGraph, fetchTaskBody } from "@/lib/query/queries";
import type {
ProjectGraphSlim,
TaskEdgeRef,
TaskFullWithEdges,
TaskGraphSlim,
} from "@/lib/data/views";

Expand Down Expand Up @@ -345,14 +347,52 @@ function WorkspaceBodyWithSelection(props: WorkspaceBodyWithSelectionProps) {
const qc = useQueryClient();
const taskId = taskSlim.id;

const { data: selectedTaskFull } = useQuery({
const { data: selectedTaskFull, isPlaceholderData } = useQuery({
queryKey: taskKeys.detail(projectId, taskId),
queryFn: fetchTaskBody(qc, projectId, taskId),
placeholderData: (): TaskFullWithEdges | undefined => {
const cached = qc.getQueryData<ProjectGraphSlim>(
projectKeys.graph(projectId),
);
const slim = cached?.tasks.find((t) => t.id === taskId);
if (!slim) return undefined;
// Fields the slim projection lacks (description, sequenceNumber,
// createdAt, files, assignees, ...) are fabricated empties below.
// Anything that renders or mutates them must stay gated on
// `isPlaceholderData` until the real detail fetch resolves.
return {
id: slim.id,
projectId,
title: slim.title,
sequenceNumber: 0,
description: "",
status: slim.status,
order: slim.order,
category: slim.category ?? null,
implementationPlan: null,
executionRecord: null,
tags: slim.tags ?? [],
priority: slim.priority ?? null,
estimate: slim.estimate ?? null,
files: [],
history: [],
createdAt: new Date(),
updatedAt: slim.updatedAt,
taskRef: slim.taskRef,
assignees: [],
acceptanceCriteria: [],
decisions: [],
links: [],
edges: [],
};
},
});

const showBodySkeleton = useSkeletonVisibility(isPlaceholderData, taskId);

const taskFullMatches = selectedTaskFull && selectedTaskFull.id === taskId;
const taskEdges: TaskEdgeRef[] =
taskFullMatches && selectedTaskFull
taskFullMatches && selectedTaskFull && !isPlaceholderData
? selectedTaskFull.edges
: graph.edges
.filter((e) => e.sourceTaskId === taskId || e.targetTaskId === taskId)
Expand Down Expand Up @@ -382,6 +422,8 @@ function WorkspaceBodyWithSelection(props: WorkspaceBodyWithSelectionProps) {
onTogglePropRail={
showPropRailToggle ? () => setPropRailOpen((v) => !v) : undefined
}
isBodyLoading={isPlaceholderData}
showBodySkeleton={showBodySkeleton}
/>
) : (
<DetailLoading />
Expand All @@ -408,6 +450,7 @@ function WorkspaceBodyWithSelection(props: WorkspaceBodyWithSelectionProps) {
projectName={graph.project.title}
onSelectNode={handleSelectNode}
onGraphChange={refreshAll}
isBodyLoading={isPlaceholderData}
/>
) : null;

Expand Down
4 changes: 2 additions & 2 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { TopBar } from "@/components/layout/TopBar";
import { AppShell } from "@/components/layout/AppShell";
import { getSession } from "@/lib/auth/session";
import { listOAuthSessionsAction } from "@/lib/actions/oauth-session";
import { listUserTeamsAction } from "@/lib/actions/team-list";
import { loadUserTeams } from "@/lib/server/request-loaders";
import { SettingsView } from "./_components/SettingsView";

/** Force dynamic rendering — this page reads the session and DB. */
Expand All @@ -23,7 +23,7 @@ export default async function SettingsPage() {

const [sessionsResult, teamsResult] = await Promise.all([
listOAuthSessionsAction(),
listUserTeamsAction(),
loadUserTeams(),
]);

const initialSessions = sessionsResult.ok ? sessionsResult.data : [];
Expand Down
10 changes: 6 additions & 4 deletions components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { type ReactNode } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth/session";
import { listProjectsSlim } from "@/lib/graph/queries";
import { listUserTeamsAction } from "@/lib/actions/team-list";
import {
loadSidebarProjects,
loadUserTeams,
} from "@/lib/server/request-loaders";
import {
Sidebar,
type SidebarProject,
Expand Down Expand Up @@ -40,8 +42,8 @@ export async function AppShell({ children }: AppShellProps) {
if (!session) redirect("/sign-in");

const [projects, teamsResult, cookieStore] = await Promise.all([
listProjectsSlim(),
listUserTeamsAction(),
loadSidebarProjects(),
loadUserTeams(),
cookies(),
]);
const initialSidebarCollapsed =
Expand Down
15 changes: 15 additions & 0 deletions components/workspace/DetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ interface DetailPanelProps {
propRailOpen?: boolean;
/** Toggle the properties rail open/closed; when omitted the toggle is hidden. */
onTogglePropRail?: () => void;
/**
* When true the header is rendered from placeholder data and the body
* withholds its content until the full detail fetch resolves.
*/
isBodyLoading?: boolean;
/**
* When true the body renders skeleton blocks. Derived from
* `isBodyLoading` via `useSkeletonVisibility` (show delay + minimum
* visible hold) so fast fetches never flash a skeleton.
*/
showBodySkeleton?: boolean;
/** Additional CSS classes. */
className?: string;
}
Expand Down Expand Up @@ -73,6 +84,8 @@ export function DetailPanel({
onToggleNavigator,
propRailOpen,
onTogglePropRail,
isBodyLoading = false,
showBodySkeleton = false,
className = "",
}: DetailPanelProps) {
return (
Expand All @@ -95,6 +108,8 @@ export function DetailPanel({
onToggleNavigator={onToggleNavigator}
propRailOpen={propRailOpen}
onTogglePropRail={onTogglePropRail}
isBodyLoading={isBodyLoading}
showBodySkeleton={showBodySkeleton}
/>
</div>
);
Expand Down
Loading