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
15 changes: 15 additions & 0 deletions docs/dynamo-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Project metadata. `id` is 8-char hex.
| cycle_pause_reason | string? | `time_expired` · `blocked` · `failures` · `manual` |
| cycle_feedback | string? | Human notes for next planner pass |
| next_check_at | string? | Agent-requested deferral (ISO); empty if none |
| reply_pending | bool? | When true, EC2 poller runs `run_task.py --pm-reply` for project-level PM chat |

**GSI:** project-list-index (`proj_status`, `project_updated`).

Expand All @@ -190,6 +191,20 @@ Human-authored daily directives.

---

### Project CHAT — `pk=PROJECT#{id} sk=CHAT#{iso}`

Project-level PM / system chat thread (human, `pm-agent`, or `system`).

| Attribute | Type | Description |
|-----------|------|-------------|
| author | string | `web` / human label · `pm-agent` · `system` |
| body | string | Markdown message |
| created_at | string | ISO 8601 (matches sk suffix) |

**Access:** `Query pk, sk begins_with "CHAT#"` — `ScanIndexForward=false, Limit=N` then reverse for chronological order.

---

### SNAPSHOT — `pk=PROJECT#{id} sk=SNAPSHOT#{YYYY-MM-DD}`

Daily metric readings from the Metrics Lambda.
Expand Down
4 changes: 2 additions & 2 deletions frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ src/
pages/Stats.tsx — Usage & cost (today / all-time tokens, budget bar, 14-day chart via GET /api/stats and /api/budget)
pages/ProjectList.tsx — Project list with status, task progress, last directive, target repo
pages/ProjectCreate.tsx — New project form: title, spec (markdown) with optional “Generate from prompt” (Bedrock via API), priority, target repo, autopilot toggle
pages/ProjectDetail.tsx — Spec editor (Generate while editing), progress bar, autopilot Off/Daily/Continuous + plan timeline (continuous), cycle start/stop/review, daily plan approve/regenerate, directive timeline, active tasks; polls 3s while busy
pages/ProjectDetail.tsx — Spec editor (Generate while editing), progress bar, **Project manager** chat (GET/POST `/projects/:id/chat`, polls while `reply_pending`), autopilot Off/Daily/Continuous + plan timeline (continuous), cycle start/stop/review, daily plan approve/regenerate, directive timeline, active tasks; polls 3s while busy
pages/Login.tsx — Sign-in page
lib/api.ts — Fetch client; project CRUD; plan APIs (fetchPlans, fetchPlanDetail, approvePlan, regeneratePlan, patchPlanItems); continuous autopilot cycle (startAutopilotCycle, stopAutopilotCycle, reviewAutopilotCycle)
lib/api.ts — Fetch client; project CRUD; `fetchProjectChat` / `postProjectChat`; plan APIs (fetchPlans, fetchPlanDetail, approvePlan, regeneratePlan, patchPlanItems); continuous autopilot cycle (startAutopilotCycle, stopAutopilotCycle, reviewAutopilotCycle)
lib/types.ts — TypeScript interfaces matching the /api/* responses; Task includes role: string, project_id, directive_sk; Project (with autopilot boolean), Directive, DailyPlan, PlanItem, ProjectListItem types
lib/time.ts — timeAgo utility
index.css — Tailwind imports, dark theme CSS vars, .prose-custom styles (colors use CSS var tokens, not hardcoded oklch literals)
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
KPI,
DailyPlan,
PlanItem,
ProjectChatMessage,
} from "./types"

const BASE = (import.meta.env.VITE_API_BASE_URL ?? "") + "/api"
Expand Down Expand Up @@ -238,6 +239,22 @@ export async function fetchProjectDetail(id: string) {
}>(`/projects/${id}`)
}

export async function fetchProjectChat(projectId: string, limit = 50) {
return request<{ messages: ProjectChatMessage[]; reply_pending: boolean }>(
`/projects/${projectId}/chat?limit=${limit}`
)
}

export async function postProjectChat(projectId: string, body: string) {
return request<{ message: ProjectChatMessage; reply_pending: boolean }>(
`/projects/${projectId}/chat`,
{
method: "POST",
body: JSON.stringify({ body }),
}
)
}

export async function createProject(data: {
title: string
spec?: string
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ export interface Project {
cycle_pause_reason?: CyclePauseReason
cycle_feedback?: string
next_check_at?: string
/** PM project chat: agent reply in flight */
reply_pending?: boolean
}

export interface ProjectChatMessage {
author: string
body: string
created_at: string
}

export interface ProjectListItem extends Project {
Expand Down
129 changes: 126 additions & 3 deletions frontend/src/pages/ProjectDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,19 @@ import {
startAutopilotCycle,
stopAutopilotCycle,
reviewAutopilotCycle,
fetchProjectChat,
postProjectChat,
} from "@/lib/api"
import type { Snapshot } from "@/lib/api"
import type { Project, Directive, Task, TaskStatus, KPI, DailyPlan } from "@/lib/types"
import type {
Project,
Directive,
Task,
TaskStatus,
KPI,
DailyPlan,
ProjectChatMessage,
} from "@/lib/types"
import { timeAgo } from "@/lib/time"

const STATUS_BADGE: Record<string, string> = {
Expand Down Expand Up @@ -197,6 +207,9 @@ export default function ProjectDetail() {
const [cycleHoursDraft, setCycleHoursDraft] = useState("24")
const [reviewFeedback, setReviewFeedback] = useState("")
const [cycleActionLoading, setCycleActionLoading] = useState(false)
const [pmChatMessages, setPmChatMessages] = useState<ProjectChatMessage[]>([])
const [pmChatBody, setPmChatBody] = useState("")
const [pmChatSending, setPmChatSending] = useState(false)
/** When true, polling must not overwrite the spec textarea (draft). */
const specEditingRef = useRef(false)
useEffect(() => {
Expand All @@ -209,9 +222,14 @@ export default function ProjectDetail() {
return Promise.all([
fetchProjectDetail(projectId),
fetchSnapshots(projectId),
fetchProjectChat(projectId).catch(() => ({
messages: [] as ProjectChatMessage[],
reply_pending: false,
})),
])
.then(async ([d, s]) => {
.then(async ([d, s, chat]) => {
setProject(d.project)
setPmChatMessages(chat.messages)
setDirectives(d.directives)
setTasks(d.tasks)
setProgress(d.progress)
Expand Down Expand Up @@ -277,7 +295,8 @@ export default function ProjectDetail() {
tasks.some((t) => t.status === "pending" || t.status === "in_progress") ||
(project && !project.awaiting_next_directive && project.active_directive_sk) ||
(todayPlan?.status === "approved" &&
planTasks.some((t) => t.status === "pending" || t.status === "in_progress"))
planTasks.some((t) => t.status === "pending" || t.status === "in_progress")) ||
Boolean(project?.reply_pending)

const humanTasksOpen = useMemo(
() =>
Expand Down Expand Up @@ -438,6 +457,110 @@ export default function ProjectDetail() {
</div>
</div>

{/* Project manager chat */}
<section className="mb-8">
<h2 className="mb-3 text-[13px] font-medium uppercase tracking-wide text-zinc-500">
Project manager
</h2>
<p className="mb-3 text-[12px] text-zinc-500">
Chat with the PM agent for status and context. It can use project data and create tasks when
needed.
</p>
{pmChatMessages.length === 0 && (
<p className="mb-3 text-[13px] text-zinc-600">No messages yet — say hello below.</p>
)}
<div className="mb-3 space-y-3">
{pmChatMessages.map((c, i) => {
const isPm = c.author === "pm-agent"
const isSystem = c.author === "system"
return (
<div key={`${c.created_at}-${i}`} className="flex gap-3">
<div
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
isPm
? "bg-indigo-500/20 text-indigo-400"
: isSystem
? "bg-zinc-600/30 text-zinc-500"
: "bg-zinc-700/50 text-zinc-400"
}`}
>
{isPm ? (
<Bot className="size-3.5" />
) : isSystem ? (
<span className="text-[9px] font-bold">SYS</span>
) : (
<User className="size-3.5" />
)}
</div>
<div
className={`min-w-0 flex-1 space-y-1 rounded-lg border px-3 py-2.5 ${
isSystem
? "border-zinc-800/30 bg-zinc-950/40 italic text-zinc-500"
: "border-zinc-800/40 bg-zinc-900/30"
}`}
>
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<span
className={`font-medium ${
isPm ? "text-indigo-400" : isSystem ? "text-zinc-500" : "text-zinc-400"
}`}
>
{isPm ? "PM" : isSystem ? "System" : c.author}
</span>
<span>·</span>
<span>{timeAgo(c.created_at)}</span>
</div>
<div className={`text-[13px] ${isSystem ? "text-zinc-500" : "text-zinc-300"}`}>
<Markdown>{c.body}</Markdown>
</div>
</div>
</div>
)
})}
</div>
{project.reply_pending && (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-indigo-500/20 bg-indigo-500/5 px-3 py-2.5">
<Loader className="size-3.5 shrink-0 animate-spin text-indigo-400" />
<span className="text-[13px] text-indigo-400">PM is preparing a reply…</span>
</div>
)}
<form
className="flex gap-2"
onSubmit={async (e) => {
e.preventDefault()
if (!projectId || !pmChatBody.trim()) return
setPmChatSending(true)
try {
const r = await postProjectChat(projectId, pmChatBody.trim())
setPmChatMessages((prev) => [...prev, r.message])
setProject((p) => (p ? { ...p, reply_pending: r.reply_pending } : p))
setPmChatBody("")
toast.success("Message sent")
await load()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to send")
} finally {
setPmChatSending(false)
}
}}
>
<Textarea
placeholder="Ask for a status update or share context…"
value={pmChatBody}
onChange={(e) => setPmChatBody(e.target.value)}
className="h-10 min-h-10 resize-none border-zinc-700/60 bg-zinc-900/40 py-2 transition-all focus:h-24"
/>
<Button
type="submit"
size="sm"
disabled={pmChatSending || !pmChatBody.trim()}
className="h-10 shrink-0 px-3"
>
<Send className="size-3.5" />
</Button>
</form>
</section>

{/* KPI Dashboard */}
<section className="mb-8">
<div className="mb-3 flex items-center justify-between">
Expand Down
80 changes: 80 additions & 0 deletions infra/packages/api/src/lib/dynamo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
DailyPlan,
PlanItem,
PlanStatus,
ProjectChatMessage,
} from "./types.js";

const TABLE_NAME = process.env.DYNAMO_TABLE ?? "agent-tasks";
Expand Down Expand Up @@ -689,6 +690,7 @@ function itemToProject(item: Record<string, unknown>): Project {
cycle_pause_reason: (item.cycle_pause_reason as Project["cycle_pause_reason"]) ?? "",
cycle_feedback: (item.cycle_feedback as string) ?? "",
next_check_at: (item.next_check_at as string) ?? "",
reply_pending: Boolean(item.reply_pending),
};
}

Expand Down Expand Up @@ -743,6 +745,7 @@ export async function createProject(params: {
cycle_pause_reason: "",
cycle_feedback: "",
next_check_at: "",
reply_pending: false,
};
if (params.target_repo?.trim()) item.target_repo = params.target_repo.trim();
await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: item }));
Expand Down Expand Up @@ -809,6 +812,7 @@ export async function updateProject(
cycle_pause_reason: Project["cycle_pause_reason"];
cycle_feedback: string;
next_check_at: string;
reply_pending: boolean;
}>
): Promise<Project | null> {
const p = await getProject(projectId);
Expand Down Expand Up @@ -910,6 +914,11 @@ export async function updateProject(
vals[":nc"] = updates.next_check_at;
sets.push("#nc = :nc");
}
if (updates.reply_pending !== undefined) {
names["#rp"] = "reply_pending";
vals[":rp"] = updates.reply_pending;
sets.push("#rp = :rp");
}
let updateExpr = `SET ${sets.join(", ")}`;
if (removes.length) updateExpr += ` REMOVE ${removes.join(", ")}`;
const resp = await ddb.send(
Expand All @@ -925,6 +934,77 @@ export async function updateProject(
return resp.Attributes ? itemToProject(resp.Attributes) : null;
}

function itemToProjectChatMessage(
item: Record<string, unknown>
): ProjectChatMessage {
return {
author: (item.author as string) ?? "",
body: (item.body as string) ?? "",
created_at: (item.created_at as string) ?? "",
};
}

/** Recent project chat messages (oldest first), plus PM reply flag from PROJECT item. */
export async function getProjectChat(
projectId: string,
limit = 50
): Promise<{ messages: ProjectChatMessage[]; reply_pending: boolean } | null> {
const p = await getProject(projectId);
if (!p) return null;
const resp = await ddb.send(
new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: "pk = :pk AND begins_with(sk, :prefix)",
ExpressionAttributeValues: {
":pk": projectPk(projectId),
":prefix": "CHAT#",
},
ScanIndexForward: false,
Limit: limit,
})
);
const items = [...(resp.Items ?? [])].reverse();
return {
messages: items.map(itemToProjectChatMessage),
reply_pending: p.reply_pending,
};
}

export async function addProjectChatMessage(
projectId: string,
author: string,
body: string,
requestPmReply: boolean
): Promise<{ message: ProjectChatMessage; reply_pending: boolean } | null> {
const p = await getProject(projectId);
if (!p) return null;
const trimmed = body.trim();
if (!trimmed) return null;
const auth = (author || "web").trim() || "web";
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00");
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: projectPk(projectId),
sk: `CHAT#${now}`,
author: auth,
body: trimmed,
created_at: now,
},
})
);
let replyPending = p.reply_pending;
if (requestPmReply) {
const updated = await updateProject(projectId, { reply_pending: true });
replyPending = updated?.reply_pending ?? true;
}
return {
message: { author: auth, body: trimmed, created_at: now },
reply_pending: replyPending,
};
}

export async function deleteProject(projectId: string): Promise<boolean> {
const resp = await ddb.send(
new QueryCommand({
Expand Down
9 changes: 9 additions & 0 deletions infra/packages/api/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ export interface Project {
cycle_feedback: string;
/** Agent-requested “check back after” time (ISO); empty if none */
next_check_at: string;
/** PM chat: EC2 poller should run run_task.py --pm-reply */
reply_pending: boolean;
}

/** Project-level PM / system chat thread */
export interface ProjectChatMessage {
author: string;
body: string;
created_at: string;
}

export type PlanStatus = "proposed" | "approved" | "executing" | "completed";
Expand Down
Loading
Loading