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:
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
1
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user