Domain primitives: versioned WorkDefinition/DesignPacket with risk, questions, and approval gates; lifecycle transition table with invalidation rules; Resolver (Run/Attempt/outcomes/CodingKitV0); provider-neutral HarnessRuntime with normalized events and a deterministic FakeHarnessLive that always reaches a terminal classification and never claims implementation. Convex durable model: workDefinitions, workQuestions, workApprovals, designPackets, workSlices, workRuns, workAttempts, workAttemptEvents tables; broadened works.status union; generalized workEvents with optional signalId, referenceId, and payloadJson. Authenticated commands for definition request/ save/revise/approve, question answer/withdraw, design save/revise/approve, and simulated execution start/cancel/retry with leased attempts, checkpointed events, and expired-lease reconciliation. Private work-planner FLUE agent with proposal-only tools (definition, design, question). Convex validates and stores every proposal; FLUE never approves or advances Work. Expanded Work card with Outcome, Design, and Build sections wired to the new mutations. Slice 1 conversation and provenance preserved.
658 lines
23 KiB
TypeScript
658 lines
23 KiB
TypeScript
import { projectWorkNotices } from "@code/primitives/work";
|
|
import {
|
|
Conversation,
|
|
ConversationContent,
|
|
} from "@code/ui/components/ai-elements/conversation";
|
|
import { Button } from "@code/ui/components/button";
|
|
import {
|
|
ChevronRight,
|
|
Check,
|
|
FolderGit2,
|
|
Hammer,
|
|
LoaderCircle,
|
|
Menu,
|
|
MessageSquareText,
|
|
ImagePlus,
|
|
Play,
|
|
RotateCcw,
|
|
Send,
|
|
Sparkles,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useMemo, useRef, useState } from "react";
|
|
|
|
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
|
|
import { ChatMessage } from "@/components/chat/chat-message";
|
|
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
|
|
import { useChatImages } from "@/hooks/chat/use-chat-images";
|
|
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
|
|
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
|
|
import {
|
|
buildSliceOneTimeline,
|
|
findSourceMessageTarget,
|
|
} from "@/lib/slice-one/presentation";
|
|
|
|
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
|
|
const EMPTY_WORKS: readonly SliceWork[] = [];
|
|
|
|
interface WorkCardProps {
|
|
readonly onSourceSelect: (rawText: string) => void;
|
|
readonly work: SliceWork;
|
|
readonly slice: SliceOneState;
|
|
}
|
|
|
|
const starterDefinition = (work: SliceWork) => ({
|
|
acceptanceCriteria: ["The requested outcome is observable and documented"],
|
|
affectedUsers: ["Project users"],
|
|
assumptions: [],
|
|
constraints: [],
|
|
desiredOutcome: work.objective,
|
|
inScope: [work.objective],
|
|
outOfScope: ["Unrelated product changes"],
|
|
problem: work.objective,
|
|
questions: [],
|
|
requiredArtifacts: ["Simulation activity and terminal outcome"],
|
|
risk: "medium",
|
|
});
|
|
|
|
const starterDesign = (work: SliceWork) => ({
|
|
architectureSummary:
|
|
"Validate the approved Definition, then exercise one deterministic fake slice.",
|
|
callFlowDelta: [
|
|
"Work -> Run -> Attempt -> normalized events -> terminal outcome",
|
|
],
|
|
concerns: [],
|
|
evidenceRequirements: ["Terminal Run classification"],
|
|
fileTreeDelta: [],
|
|
impactMap: {
|
|
files: [],
|
|
modules: [],
|
|
risks: [],
|
|
summary: "Compact vertical-slice simulation",
|
|
},
|
|
invariants: [
|
|
"Simulation never claims implementation",
|
|
"Every Attempt reaches a terminal classification",
|
|
],
|
|
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
|
|
slices: [
|
|
{
|
|
codeBoundaries: ["workExecution"],
|
|
dependsOn: [],
|
|
evidenceRequirements: ["Normalized activity events"],
|
|
id: "slice-1",
|
|
objective: work.objective,
|
|
observableBehavior: "A terminal fake Run is visible",
|
|
reviewRequired: false,
|
|
title: "Deterministic simulation",
|
|
verification: ["Run completes with a terminal classification"],
|
|
},
|
|
],
|
|
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
|
|
});
|
|
|
|
// oxlint-disable-next-line complexity -- the expanded card intentionally keeps the three review sections together.
|
|
const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
|
|
const [sourcesOpen, setSourcesOpen] = useState(false);
|
|
const [expanded, setExpanded] = useState(false);
|
|
const sources = work.signals.flatMap((signal) => signal.sources);
|
|
const { definition } = work;
|
|
const { design } = work;
|
|
const [latestRun] = work.runs;
|
|
return (
|
|
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
|
|
<div className="flex items-start gap-3">
|
|
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
|
|
<Sparkles className="size-4" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
|
|
Proposed Work
|
|
</p>
|
|
<h2 className="mt-1 text-[15px] font-semibold leading-5">
|
|
{work.title}
|
|
</h2>
|
|
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
|
|
{work.objective}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
|
|
onClick={() => setSourcesOpen((open) => !open)}
|
|
type="button"
|
|
>
|
|
<span>
|
|
{sources.length} exact source{" "}
|
|
{sources.length === 1 ? "message" : "messages"}
|
|
</span>
|
|
<ChevronRight
|
|
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
|
|
/>
|
|
</button>
|
|
<button
|
|
className="mt-2 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs font-medium text-[#20201d]"
|
|
onClick={() => setExpanded((open) => !open)}
|
|
type="button"
|
|
>
|
|
<span>{expanded ? "Hide Work details" : "Open Work details"}</span>
|
|
<ChevronRight
|
|
className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
|
|
/>
|
|
</button>
|
|
{sourcesOpen ? (
|
|
<div className="mt-3 space-y-2">
|
|
{sources.map((source) => (
|
|
<button
|
|
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
|
|
key={source.messageId}
|
|
onClick={() => onSourceSelect(source.rawText)}
|
|
type="button"
|
|
>
|
|
<MessageSquareText className="mt-1 size-3.5 shrink-0 text-[#65713a]" />
|
|
<span className="min-w-0 flex-1">{source.rawText}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{expanded ? (
|
|
<div className="mt-4 space-y-4 border-t border-[#e7e3d9] pt-4 text-xs">
|
|
<section>
|
|
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
|
Outcome
|
|
</p>
|
|
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
|
|
<p className="mt-2 text-[#747168]">
|
|
Risk: {definition?.risk ?? "not defined"}
|
|
</p>
|
|
{definition?.questions?.length ? (
|
|
<p className="mt-1 text-amber-800">
|
|
{
|
|
definition.questions.filter(
|
|
(question) => question.status === "open"
|
|
).length
|
|
}{" "}
|
|
open question(s)
|
|
</p>
|
|
) : null}
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{work.status === "proposed" ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void slice.requestDefinition(work._id)}
|
|
>
|
|
<Sparkles className="size-3.5" /> Define
|
|
</Button>
|
|
) : null}
|
|
{work.status === "defining" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() =>
|
|
void slice.saveDefinition(work._id, starterDefinition(work))
|
|
}
|
|
>
|
|
<Hammer className="size-3.5" /> Save definition
|
|
</Button>
|
|
) : null}
|
|
{work.status === "awaiting-definition-approval" &&
|
|
work.definitionVersion ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() =>
|
|
void slice.approveDefinition(
|
|
work._id,
|
|
work.definitionVersion as number
|
|
)
|
|
}
|
|
>
|
|
<Check className="size-3.5" /> Approve
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
<section>
|
|
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
|
Design
|
|
</p>
|
|
<p className="mt-1 leading-5 text-[#626057]">
|
|
{design?.architectureSummary ?? "No Design Packet yet."}
|
|
</p>
|
|
{design?.slices?.map((item) => (
|
|
<p className="mt-1 text-[#747168]" key={item.id}>
|
|
{item.title}: {item.observableBehavior}
|
|
</p>
|
|
))}
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{work.status === "designing" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() =>
|
|
void slice.saveDesign(work._id, starterDesign(work))
|
|
}
|
|
>
|
|
<Hammer className="size-3.5" /> Save design
|
|
</Button>
|
|
) : null}
|
|
{work.status === "awaiting-design-approval" &&
|
|
work.definitionApprovalVersion &&
|
|
work.designVersion ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() =>
|
|
void slice.approveDesign(
|
|
work._id,
|
|
work.definitionApprovalVersion as number,
|
|
work.designVersion as number
|
|
)
|
|
}
|
|
>
|
|
<Check className="size-3.5" /> Approve design
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
<section>
|
|
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
|
Build
|
|
</p>
|
|
{latestRun ? (
|
|
<p className="mt-1 leading-5 text-[#626057]">
|
|
Run {latestRun.status}:{" "}
|
|
{latestRun.terminalSummary ??
|
|
latestRun.terminalClassification ??
|
|
"activity is still arriving"}
|
|
</p>
|
|
) : (
|
|
<p className="mt-1 text-[#747168]">No simulation Run yet.</p>
|
|
)}
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{work.status === "ready" ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() =>
|
|
void slice.startSimulation(
|
|
work._id,
|
|
"success",
|
|
design?.slices?.[0]?.id
|
|
)
|
|
}
|
|
>
|
|
<Play className="size-3.5" /> Simulate
|
|
</Button>
|
|
) : null}
|
|
{latestRun?.status === "running" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void slice.cancelSimulation(latestRun._id)}
|
|
>
|
|
<X className="size-3.5" /> Cancel
|
|
</Button>
|
|
) : null}
|
|
{latestRun?.status === "terminal" &&
|
|
latestRun.terminalClassification === "RetryableFailure" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void slice.retrySimulation(latestRun._id)}
|
|
>
|
|
<RotateCcw className="size-3.5" /> Retry
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
);
|
|
};
|
|
|
|
const ConversationLoading = () => (
|
|
<output
|
|
aria-label="Loading conversation"
|
|
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
|
|
>
|
|
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
|
|
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
|
|
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
|
|
<span className="sr-only">Loading conversation…</span>
|
|
</output>
|
|
);
|
|
|
|
const ConversationEmptyState = () => (
|
|
<div className="grid min-h-[55vh] place-items-center text-center">
|
|
<div>
|
|
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
|
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
|
|
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
|
Describe an outcome or problem. Casual conversation stays conversation.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
type SliceOneState = ReturnType<typeof useSliceOne>;
|
|
|
|
const ProjectsLoading = () => (
|
|
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
|
<LoaderCircle className="size-5 animate-spin" />
|
|
</div>
|
|
);
|
|
const ConnectProject = ({ slice }: { slice: SliceOneState }) => (
|
|
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
|
|
<form
|
|
className="w-full max-w-sm"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void slice.connectRepository();
|
|
}}
|
|
>
|
|
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
|
<FolderGit2 className="size-5" />
|
|
</span>
|
|
<h1 className="mt-6 text-2xl font-semibold">Connect one project</h1>
|
|
<p className="mt-2 text-sm leading-6 text-[#68665e]">
|
|
Slice 1 turns actionable conversation into proposed Work with exact
|
|
provenance.
|
|
</p>
|
|
<input
|
|
aria-label="Public Git repository URL"
|
|
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
|
onChange={(event) => slice.setRepository(event.target.value)}
|
|
placeholder="https://github.com/owner/repository"
|
|
required
|
|
value={slice.repository}
|
|
/>
|
|
{slice.error ? (
|
|
<p className="mt-2 text-xs text-red-700">{slice.error.message}</p>
|
|
) : null}
|
|
<Button
|
|
className="mt-3 h-12 w-full"
|
|
disabled={slice.pending}
|
|
type="submit"
|
|
>
|
|
{slice.pending ? (
|
|
<LoaderCircle className="size-4 animate-spin" />
|
|
) : null}
|
|
{slice.pending ? "Connecting" : "Connect project"}
|
|
</Button>
|
|
</form>
|
|
</main>
|
|
);
|
|
|
|
export const SliceOnePage = () => {
|
|
const slice = useSliceOne();
|
|
const viewportStyle = useVisualViewportStyle();
|
|
const [draft, setDraft] = useState("");
|
|
const attachments = useChatImages();
|
|
const imageInput = useRef<HTMLInputElement>(null);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
|
|
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const works = slice.works ?? EMPTY_WORKS;
|
|
const workById = useMemo(
|
|
() => new Map(works.map((work) => [String(work._id), work])),
|
|
[works]
|
|
);
|
|
const notices = useMemo(() => projectWorkNotices(works), [works]);
|
|
const timeline = useMemo(
|
|
() => buildSliceOneTimeline(slice.agent.messages, notices),
|
|
[notices, slice.agent.messages]
|
|
);
|
|
const busy =
|
|
slice.agent.status === "submitted" || slice.agent.status === "streaming";
|
|
|
|
const revealSourceMessage = (rawText: string) => {
|
|
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
|
|
if (!messageId) {
|
|
return;
|
|
}
|
|
setDrawerOpen(false);
|
|
setHighlightedMessageId(messageId);
|
|
if (highlightTimer.current) {
|
|
clearTimeout(highlightTimer.current);
|
|
}
|
|
requestAnimationFrame(() => {
|
|
document
|
|
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
|
|
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
});
|
|
highlightTimer.current = setTimeout(
|
|
() => setHighlightedMessageId(undefined),
|
|
1800
|
|
);
|
|
};
|
|
|
|
if (slice.projects === undefined) {
|
|
return <ProjectsLoading />;
|
|
}
|
|
|
|
if (!slice.selectedProject) {
|
|
return <ConnectProject slice={slice} />;
|
|
}
|
|
|
|
const send = async () => {
|
|
const message = draft.trim();
|
|
if (!message || busy) {
|
|
return;
|
|
}
|
|
await slice.agent.sendMessage(message, {
|
|
images: attachments.images.map((image) => image.file),
|
|
});
|
|
setDraft("");
|
|
attachments.clear();
|
|
};
|
|
|
|
return (
|
|
<main
|
|
className="slice-one-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
|
|
style={viewportStyle}
|
|
>
|
|
<section className="flex min-w-0 flex-1 flex-col">
|
|
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
|
<div className="min-w-0 flex-1 pr-2">
|
|
<select
|
|
aria-label="Current project"
|
|
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
|
|
onChange={(event) => slice.selectProject(event.target.value)}
|
|
value={slice.selectedProject.id}
|
|
>
|
|
{slice.projects.map((project) => (
|
|
<option key={project.id} value={project.id}>
|
|
{project.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="text-[10px] uppercase text-[#858277]">
|
|
Conversation to proposed Work
|
|
</p>
|
|
</div>
|
|
<button
|
|
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
|
onClick={() => setDrawerOpen(true)}
|
|
type="button"
|
|
>
|
|
<Menu className="size-4" /> Work{" "}
|
|
{slice.works === undefined ? "…" : works.length}
|
|
</button>
|
|
</header>
|
|
<Conversation className="min-h-0 flex-1">
|
|
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
|
|
{!slice.agent.historyReady && timeline.length === 0 ? (
|
|
<ConversationLoading />
|
|
) : null}
|
|
{slice.agent.historyReady && timeline.length === 0 ? (
|
|
<ConversationEmptyState />
|
|
) : null}
|
|
{timeline.map((item) => {
|
|
if (item.kind === "work") {
|
|
const work = workById.get(item.notice.workId);
|
|
return work ? (
|
|
<div
|
|
className="chat-message ml-7"
|
|
key={`notice-${item.notice.eventId}`}
|
|
>
|
|
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
|
|
Work proposed from this conversation
|
|
</p>
|
|
<WorkCard
|
|
onSourceSelect={revealSourceMessage}
|
|
slice={slice}
|
|
work={work}
|
|
/>
|
|
</div>
|
|
) : null;
|
|
}
|
|
return (
|
|
<div
|
|
className={`rounded-sm transition-colors duration-300 ${
|
|
highlightedMessageId === item.message.id
|
|
? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]"
|
|
: ""
|
|
}`}
|
|
id={`slice-message-${item.message.id}`}
|
|
key={item.message.id}
|
|
>
|
|
<ChatMessage message={item.message} />
|
|
</div>
|
|
);
|
|
})}
|
|
{slice.agent.status === "submitted" ? (
|
|
<ChatThinkingResponse />
|
|
) : null}
|
|
</ConversationContent>
|
|
</Conversation>
|
|
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
|
{attachments.images.length > 0 ? (
|
|
<div className="mx-auto max-w-2xl">
|
|
<PendingChatAttachments
|
|
images={attachments.images}
|
|
onRemove={attachments.handleRemove}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
{slice.agent.error ? (
|
|
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
|
{slice.agent.error.message}
|
|
</p>
|
|
) : null}
|
|
{attachments.error ? (
|
|
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
|
{attachments.error}
|
|
</p>
|
|
) : null}
|
|
<div className="mx-auto flex max-w-2xl items-end gap-2">
|
|
<input
|
|
accept="image/*"
|
|
aria-label="Attach images"
|
|
className="sr-only"
|
|
multiple
|
|
onChange={(event) => {
|
|
attachments.addFiles(event.target.files);
|
|
event.target.value = "";
|
|
}}
|
|
ref={imageInput}
|
|
type="file"
|
|
/>
|
|
<Button
|
|
aria-label="Attach images"
|
|
className="size-11 shrink-0"
|
|
disabled={busy}
|
|
onClick={() => imageInput.current?.click()}
|
|
size="icon"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<ImagePlus className="size-4" />
|
|
</Button>
|
|
<textarea
|
|
aria-label="Message Zopu"
|
|
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
|
|
disabled={busy}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void send();
|
|
}
|
|
}}
|
|
placeholder="Describe an outcome or problem…"
|
|
rows={1}
|
|
value={draft}
|
|
/>
|
|
<Button
|
|
aria-label="Send message"
|
|
className="size-11 shrink-0"
|
|
disabled={!draft.trim() || busy}
|
|
onClick={() => void send()}
|
|
size="icon"
|
|
type="button"
|
|
>
|
|
{busy ? (
|
|
<LoaderCircle className="size-4 animate-spin" />
|
|
) : (
|
|
<Send className="size-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
|
|
<h2 className="text-sm font-semibold">Proposed Work</h2>
|
|
<p className="mb-4 text-xs text-[#747168]">
|
|
{works.length} durable outcomes
|
|
</p>
|
|
<div className="space-y-3">
|
|
{works.map((work) => (
|
|
<WorkCard
|
|
key={work._id}
|
|
onSourceSelect={revealSourceMessage}
|
|
slice={slice}
|
|
work={work}
|
|
/>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
{drawerOpen ? (
|
|
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
|
|
<button
|
|
aria-label="Close Work drawer"
|
|
className="absolute inset-0"
|
|
onClick={() => setDrawerOpen(false)}
|
|
type="button"
|
|
/>
|
|
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
|
|
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
|
|
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
|
|
<button
|
|
aria-label="Close Work drawer"
|
|
className="grid size-9 place-items-center"
|
|
onClick={() => setDrawerOpen(false)}
|
|
type="button"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</header>
|
|
<div className="flex-1 space-y-3 overflow-y-auto p-4">
|
|
{works.length === 0 ? (
|
|
<p className="py-12 text-center text-sm text-[#747168]">
|
|
Actionable messages will appear here.
|
|
</p>
|
|
) : null}
|
|
{works.map((work) => (
|
|
<WorkCard
|
|
key={work._id}
|
|
onSourceSelect={revealSourceMessage}
|
|
slice={slice}
|
|
work={work}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
</main>
|
|
);
|
|
};
|