feat(plan): Objective/Sessions/Tasks tab with markdown + AI scribe

- Objective: full markdown document editor with Write/Preview tabs
- Sessions: project-scoped chat threads with AI-generated summaries
- Tasks: master-detail view with markdown spec, status pills, agent
  delegation placeholder
- Chat threads now scoped per-project and auto-summarised after each
  assistant turn (powers Sessions list)
- AI MCP scribe tools: plan_get / plan_vision_set / plan_idea_add /
  plan_task_add (title + markdown desc) / plan_task_complete /
  plan_decision_log
- Chat panel clears stale project threads when navigating to workspace

Made-with: Cursor
This commit is contained in:
2026-04-30 13:44:50 -07:00
parent 652e45ac00
commit 60a04e48c1
9 changed files with 1008 additions and 198 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -500,6 +500,63 @@ export async function POST(request: Request) {
[thread_id, email, JSON.stringify(finalMsg)],
);
// Fire-and-forget: ask Gemini for a 1-2 sentence "what got done"
// summary of the conversation so far, persist it on the thread,
// and use the first user message (truncated) as a stable title
// when one isn't set yet. This is what powers the Sessions tab on
// the project Plan page — read-only chronological progress log.
// Wrapped in try/catch + .catch — the response stream is already
// closed and we don't want a summary failure to surface as an
// error to the user.
(async () => {
try {
const allMessages = [...history, finalMsg];
// Only summarize if there's something worth summarizing.
if (allMessages.length < 2) return;
const transcript = allMessages
.map((m) => {
const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
return `${m.role.toUpperCase()}: ${text.slice(0, 1200)}`;
})
.join('\n\n');
const sumResp = await callGeminiChat({
systemPrompt:
'You are summarizing a chat session for a project log. ' +
'Write 1-2 sentences (max 200 chars) describing what was actually attempted, decided, or shipped in this conversation. ' +
'Past tense, plain language, no preamble, no headings. ' +
'If nothing of substance happened, write a single short sentence describing the topic.',
messages: [{ role: 'user', content: transcript.slice(0, 8000) }],
temperature: 0.3,
});
const summary = (sumResp.text || '').trim().slice(0, 280);
// Pick a title only if the existing one is missing or generic.
const firstUser = allMessages.find((m) => m.role === 'user');
const firstText =
typeof firstUser?.content === 'string' ? firstUser.content : '';
const fallbackTitle = firstText.replace(/\s+/g, ' ').trim().slice(0, 60);
const update: Record<string, unknown> = {};
if (summary) update.summary = summary;
if (fallbackTitle) update.title = fallbackTitle;
if (Object.keys(update).length > 0) {
await query(
`UPDATE fs_chat_threads
SET data = data || $2
WHERE id = $1
AND (
($2::jsonb ? 'title') IS FALSE
OR data->>'title' IS NULL
OR data->>'title' = ''
OR data->>'title' = 'New conversation'
OR ($2::jsonb ? 'summary')
)`,
[thread_id, JSON.stringify(update)],
);
}
} catch {
// best-effort; silent failure
}
})().catch(() => {});
emit({ type: 'done' });
safeClose();
} catch (e) {

View File

@@ -64,15 +64,31 @@ export async function GET(request: Request) {
// When projectId is supplied, narrow to that project. When omitted,
// return only WORKSPACE-level threads (project_id IS NULL) so the
// workspace chat UI doesn't get spammed with every project's history.
// LEFT JOIN against fs_chat_messages so each thread row carries a
// message count. The Sessions tab on the Plan page renders this so
// the user can tell at a glance which conversations were substantive
// ("12 messages") vs. abandoned ("1 message").
const sql = projectId
? `SELECT id, project_id, data, created_at, updated_at
FROM fs_chat_threads
WHERE user_id = $1 AND workspace = $2 AND project_id = $3
ORDER BY updated_at DESC LIMIT 50`
: `SELECT id, project_id, data, created_at, updated_at
FROM fs_chat_threads
WHERE user_id = $1 AND workspace = $2 AND project_id IS NULL
ORDER BY updated_at DESC LIMIT 50`;
? `SELECT t.id, t.project_id, t.data, t.created_at, t.updated_at,
COALESCE(m.cnt, 0)::int AS message_count
FROM fs_chat_threads t
LEFT JOIN (
SELECT thread_id, COUNT(*) AS cnt
FROM fs_chat_messages
GROUP BY thread_id
) m ON m.thread_id = t.id
WHERE t.user_id = $1 AND t.workspace = $2 AND t.project_id = $3
ORDER BY t.updated_at DESC LIMIT 50`
: `SELECT t.id, t.project_id, t.data, t.created_at, t.updated_at,
COALESCE(m.cnt, 0)::int AS message_count
FROM fs_chat_threads t
LEFT JOIN (
SELECT thread_id, COUNT(*) AS cnt
FROM fs_chat_messages
GROUP BY thread_id
) m ON m.thread_id = t.id
WHERE t.user_id = $1 AND t.workspace = $2 AND t.project_id IS NULL
ORDER BY t.updated_at DESC LIMIT 50`;
const args = projectId
? [session.user.email, workspace, projectId]
: [session.user.email, workspace];
@@ -84,6 +100,8 @@ export async function GET(request: Request) {
id: r.id,
projectId: r.project_id ?? null,
title: r.data?.title || 'New conversation',
summary: r.data?.summary || null,
messageCount: r.message_count ?? 0,
updatedAt: r.updated_at,
createdAt: r.created_at,
})),

View File

@@ -3963,7 +3963,21 @@ echo "VIBN_SHIP_SHA=$(git rev-parse HEAD)"`;
// project belongs to the calling principal's workspace before mutating.
interface PlanIdea { id: string; text: string; createdAt: string }
interface PlanTask { id: string; text: string; status: 'open' | 'done'; createdAt: string; doneAt?: string }
// Tasks are markdown-bodied scoped units of work. `text` is the legacy
// field from the v1 single-line shape; `title` + `description` is the
// current shape. Reader migrates legacy rows on the fly.
type PlanTaskStatus = 'open' | 'in_progress' | 'done' | 'blocked';
interface PlanTask {
id: string;
title: string;
description?: string;
status: PlanTaskStatus;
createdAt: string;
startedAt?: string;
doneAt?: string;
text?: string; // legacy
agent?: { runId: string; startedAt: string; finishedAt?: string; status: 'queued' | 'running' | 'succeeded' | 'failed' } | null;
}
interface PlanDecision { id: string; title: string; choice: string; why?: string; createdAt: string }
interface PlanShape {
vision?: string;
@@ -3988,10 +4002,25 @@ async function loadPlanProject(principal: Principal, projectId: string) {
function readPlanFromData(data: any): PlanShape {
const raw = (data?.plan ?? {}) as Partial<PlanShape>;
// Mirror the legacy-task migration in app/api/projects/[projectId]/plan/route.ts:
// tasks created in v1 only have `text`; coerce them on the fly so the AI
// sees a consistent shape regardless of when a row was written.
const tasksIn = Array.isArray(raw.tasks) ? (raw.tasks as Array<Partial<PlanTask>>) : [];
const tasks: PlanTask[] = tasksIn.map((t) => ({
id: String(t.id ?? planNewId()),
title: String(t.title ?? t.text ?? '').trim(),
description: typeof t.description === 'string' ? t.description : '',
status: (t.status === 'in_progress' || t.status === 'done' || t.status === 'blocked' ? t.status : 'open') as PlanTaskStatus,
agent: t.agent ?? null,
createdAt: String(t.createdAt ?? new Date().toISOString()),
startedAt: t.startedAt,
doneAt: t.doneAt,
text: t.text,
}));
return {
vision: data?.productVision ?? raw.vision,
ideas: Array.isArray(raw.ideas) ? raw.ideas : [],
tasks: Array.isArray(raw.tasks) ? raw.tasks : [],
tasks,
decisions: Array.isArray(raw.decisions) ? raw.decisions : [],
};
}
@@ -4052,15 +4081,33 @@ async function toolPlanIdeaAdd(principal: Principal, params: Record<string, any>
async function toolPlanTaskAdd(principal: Principal, params: Record<string, any>) {
const projectId = String(params.projectId ?? '').trim();
const text = String(params.text ?? '').trim();
if (!projectId || !text) return NextResponse.json({ error: 'projectId and text required' }, { status: 400 });
// Accept either {title, description} (preferred) or legacy {text}.
const title = String(params.title ?? params.text ?? '').trim();
const description = typeof params.description === 'string' ? params.description : '';
if (!projectId || !title) {
return NextResponse.json({ error: 'projectId and title required' }, { status: 400 });
}
const project = await loadPlanProject(principal, projectId);
if (!project) return NextResponse.json({ error: 'Project not found in workspace' }, { status: 404 });
const plan = readPlanFromData(project.data);
const task: PlanTask = { id: planNewId(), text, status: 'open', createdAt: new Date().toISOString() };
const task: PlanTask = {
id: planNewId(),
title,
description,
status: 'open',
createdAt: new Date().toISOString(),
};
plan.tasks.unshift(task);
await writePlanForProject(projectId, plan);
return NextResponse.json({ result: { ok: true, task, summaryHint: `Task added to Plan → Tasks. Brief acknowledgment only.` } });
return NextResponse.json({
result: {
ok: true,
task,
summaryHint:
`Task added to Plan → Tasks. Tell the user it's logged and ` +
`(if relevant) that the markdown spec is ready to delegate.`,
},
});
}
async function toolPlanTaskComplete(principal: Principal, params: Record<string, any>) {

View File

@@ -27,7 +27,26 @@ import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
interface Idea { id: string; text: string; createdAt: string }
interface Task { id: string; text: string; status: "open" | "done"; createdAt: string; doneAt?: string }
type TaskStatus = "open" | "in_progress" | "done" | "blocked";
interface Task {
id: string;
// Short headline. For backward compat we accept incoming `text` and
// mirror to `title` when no explicit title is given.
title: string;
// Markdown body — the spec/plan for the unit of work. Optional on
// legacy rows (we coerce undefined → empty string at read time).
description?: string;
status: TaskStatus;
// Reserved for Phase 2 (background agent delegation). Set when an
// autonomous agent has been kicked off against this task.
agent?: { runId: string; startedAt: string; finishedAt?: string; status: "queued" | "running" | "succeeded" | "failed" } | null;
createdAt: string;
startedAt?: string;
doneAt?: string;
// Legacy: original single-line text from the v1 task model. Only
// present on rows created before the markdown migration.
text?: string;
}
interface Decision { id: string; title: string; choice: string; why?: string; createdAt: string }
interface PlanShape {
vision?: string;
@@ -56,10 +75,26 @@ async function loadOwnedProject(projectId: string, email: string) {
function readPlan(data: any): PlanShape {
const raw = (data?.plan ?? {}) as Partial<PlanShape>;
// Migrate legacy task shape ({ text }) → new shape ({ title, description })
// on read, so old rows still render correctly without a one-shot
// migration script. We DON'T persist back here; the next write will
// serialize the new shape.
const tasksIn = Array.isArray(raw.tasks) ? (raw.tasks as Array<Partial<Task>>) : [];
const tasks: Task[] = tasksIn.map((t) => ({
id: String(t.id ?? newId()),
title: String(t.title ?? t.text ?? "").trim(),
description: typeof t.description === "string" ? t.description : "",
status: (t.status === "in_progress" || t.status === "done" || t.status === "blocked" ? t.status : "open") as TaskStatus,
agent: t.agent ?? null,
createdAt: String(t.createdAt ?? new Date().toISOString()),
startedAt: t.startedAt,
doneAt: t.doneAt,
text: t.text, // keep so legacy code paths (older API consumers) still see it
}));
return {
vision: data?.productVision ?? raw.vision,
ideas: Array.isArray(raw.ideas) ? raw.ideas : [],
tasks: Array.isArray(raw.tasks) ? raw.tasks : [],
tasks,
decisions: Array.isArray(raw.decisions) ? raw.decisions : [],
};
}
@@ -132,9 +167,19 @@ export async function POST(
return NextResponse.json({ plan });
}
if (kind === "task") {
const text = String(body.text ?? "").trim();
if (!text) return NextResponse.json({ error: "text required" }, { status: 400 });
plan.tasks.unshift({ id: newId(), text, status: "open", createdAt: now });
// New richer shape: { title, description? }. Legacy callers that
// still send `text` are mapped to `title` so older AI tool versions
// and any open clients keep working.
const title = String(body.title ?? body.text ?? "").trim();
const description = typeof body.description === "string" ? body.description : "";
if (!title) return NextResponse.json({ error: "title required" }, { status: 400 });
plan.tasks.unshift({
id: newId(),
title,
description,
status: "open",
createdAt: now,
});
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}
@@ -168,10 +213,18 @@ export async function PATCH(
if (kind === "task" && id) {
const t = plan.tasks.find((x) => x.id === id);
if (!t) return NextResponse.json({ error: "task not found" }, { status: 404 });
if (typeof body.text === "string") t.text = body.text.trim();
if (body.status === "open" || body.status === "done") {
t.status = body.status;
t.doneAt = body.status === "done" ? new Date().toISOString() : undefined;
if (typeof body.title === "string") t.title = body.title.trim();
if (typeof body.description === "string") t.description = body.description;
// Backward compat: PATCH body { text } updates the title.
if (typeof body.text === "string" && typeof body.title !== "string") t.title = body.text.trim();
const validStatuses: TaskStatus[] = ["open", "in_progress", "done", "blocked"];
if (validStatuses.includes(body.status)) {
const next = body.status as TaskStatus;
const now = new Date().toISOString();
t.status = next;
if (next === "in_progress" && !t.startedAt) t.startedAt = now;
if (next === "done") t.doneAt = now;
if (next === "open") { t.doneAt = undefined; t.startedAt = undefined; }
}
await writePlan(projectId, plan);
return NextResponse.json({ plan });

View File

@@ -299,6 +299,10 @@ export function ChatPanel() {
setThreadsLoaded(false);
setActiveThread(null);
setMessages([]);
// Clear the threads array immediately so the resume effect doesn't
// race the loadThreads() fetch and resume a stale project-scoped
// thread when the user navigates from /project/X back to /projects.
setThreads([]);
loadThreads();
}, [loadThreads, projectId]);

View File

@@ -955,14 +955,15 @@ After this returns, ALWAYS call apps_deploy { uuid } to regenerate the live Trae
},
{
name: 'plan_task_add',
description: 'Add an open task. Call when (a) the user says "remind me to X", (b) you finish a tool chain and there\'s an obvious follow-up the user must do, or (c) you propose multi-step work and want to track each step. Don\'t bombard — one task per actual next-action.',
description: 'Add an open task. Tasks are SCOPED UNITS OF WORK with a markdown spec — a feature, refactor, investigation, or migration. Each task should be substantive enough that an autonomous agent could execute it. Provide a verb-led `title` AND a markdown `description` containing: ## Goal, ## Context, ## Acceptance criteria (checklist), and ## Notes if relevant. Don\'t use this for trivial reminders — only for things that warrant a brief.',
parameters: {
type: 'OBJECT',
properties: {
projectId: { type: 'STRING', description: 'The Vibn project ID.' },
text: { type: 'STRING', description: 'The task as a verb-led sentence ("Add Stripe webhook URL to env", not "Stripe webhook").' },
projectId: { type: 'STRING', description: 'The Vibn project ID.' },
title: { type: 'STRING', description: 'Short verb-led headline (e.g. "Migrate auth to NextAuth v5").' },
description: { type: 'STRING', description: 'Markdown spec for this task. Include ## Goal, ## Context, ## Acceptance criteria, optionally ## Notes. Strongly recommended.' },
},
required: ['projectId', 'text'],
required: ['projectId', 'title'],
},
},
{

1
package-lock.json generated
View File

@@ -42,6 +42,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"ssh2": "^1.17.0",

View File

@@ -58,6 +58,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"ssh2": "^1.17.0",