From 663bc77afedffff335d7b004760c557138f9133b Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Sun, 26 Apr 2026 14:34:34 -0500 Subject: [PATCH] feat: ECHODO app shell, Coolify deploy, Authentik + Umami Bundles in-flight ECHODO work with the Coolify deployment configuration: App - New routes: ai, forms, planner, settings (templates/types), teams, doc detail, whiteboard detail - New components: app shell rework (icon-rail, top-header), forms builder/renderer/responses, types manager, objects creation dialog, card primitive, form + overview views - New tRPC routers: favorites, forms, types, workspaces; updates to health and objects routers - Markdown backlog sync (packages/database) + cursor-sync schema/migrations - Schema additions: forms, types, favorites, markdown_backlog, cursor_sync - Initial Drizzle migrations checked in Deployment - docker/docker-compose.coolify.yml: drops bundled Postgres/Redis (uses CT 102 shared services), removes host port mappings, adds Coolify SERVICE_FQDN_* magic vars for web + collab - .env.example rewritten as the full ECHODO/Coolify variable manifest - NextAuth gains an Authentik OIDC provider (gated on env presence) - Root layout injects Umami tracking script when configured; metadata title flipped to ECHODO Security - .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/, credentials.*, *.key, *.crt, *.pem, ssh keys Made-with: Cursor --- .env.example | 88 +- .gitignore | 18 + apps/collab-server/src/index.ts | 57 +- .../[workspaceSlug]/[projectId]/layout.tsx | 9 +- .../[workspaceSlug]/[projectId]/page.tsx | 16 + .../web/app/(app)/[workspaceSlug]/ai/page.tsx | 162 ++ .../[workspaceSlug]/docs/[docId]/page.tsx | 161 ++ .../app/(app)/[workspaceSlug]/docs/page.tsx | 98 +- .../forms/[formId]/edit/page.tsx | 41 + .../[workspaceSlug]/forms/[formId]/page.tsx | 112 + .../app/(app)/[workspaceSlug]/forms/page.tsx | 118 + .../(app)/[workspaceSlug]/planner/page.tsx | 251 ++ .../settings/templates/page.tsx | 137 + .../[workspaceSlug]/settings/types/page.tsx | 16 + .../app/(app)/[workspaceSlug]/teams/page.tsx | 106 + .../whiteboards/[whiteboardId]/page.tsx | 43 + .../[workspaceSlug]/whiteboards/page.tsx | 99 +- apps/web/app/layout.tsx | 15 +- apps/web/app/page.tsx | 22 +- apps/web/components/editor/editor.tsx | 3 +- apps/web/components/editor/index.ts | 1 + apps/web/components/forms/form-builder.tsx | 378 +++ apps/web/components/forms/form-field-card.tsx | 182 ++ .../components/forms/form-field-config.tsx | 407 +++ .../forms/form-field-type-picker.tsx | 73 + .../components/forms/form-mapping-picker.tsx | 88 + apps/web/components/forms/form-renderer.tsx | 587 +++++ apps/web/components/forms/form-responses.tsx | 137 + apps/web/components/forms/index.ts | 7 + apps/web/components/layout/app-shell.tsx | 36 +- apps/web/components/layout/icon-rail.tsx | 200 ++ apps/web/components/layout/top-header.tsx | 260 ++ apps/web/components/layout/workspace-sync.tsx | 25 +- .../objects/create-object-dialog.tsx | 425 +++ apps/web/components/objects/index.ts | 1 + .../web/components/panels/assignee-picker.tsx | 29 +- apps/web/components/panels/object-detail.tsx | 193 +- apps/web/components/sidebar/index.ts | 2 + apps/web/components/sidebar/nav-tree.tsx | 723 +++--- .../web/components/sidebar/sidebar-header.tsx | 262 +- apps/web/components/sidebar/sidebar-nav.tsx | 225 +- apps/web/components/sidebar/sidebar.tsx | 24 +- .../components/sidebar/workspace-switcher.tsx | 47 +- .../components/templates/template-picker.tsx | 69 +- apps/web/components/types/index.ts | 3 + apps/web/components/types/type-editor.tsx | 210 ++ apps/web/components/types/type-manager.tsx | 266 ++ apps/web/components/types/type-picker.tsx | 128 + apps/web/components/ui/card.tsx | 61 + .../components/views/board/board-column.tsx | 64 +- .../web/components/views/board/board-view.tsx | 50 +- .../components/views/config/view-switcher.tsx | 4 +- apps/web/components/views/form/form-view.tsx | 105 + apps/web/components/views/list/list-view.tsx | 82 +- .../views/overview/overview-view.tsx | 125 + .../web/components/views/table/table-view.tsx | 132 +- apps/web/components/whiteboard/canvas.tsx | 13 +- apps/web/lib/auth.ts | 15 + apps/web/lib/hooks/use-view-data.ts | 45 +- apps/web/lib/stores/view-store.ts | 2 +- apps/web/server/root.ts | 8 + apps/web/server/routers/favorites.ts | 66 + apps/web/server/routers/forms.ts | 364 +++ apps/web/server/routers/health.ts | 4 +- apps/web/server/routers/objects.ts | 23 +- apps/web/server/routers/types.ts | 102 + apps/web/server/routers/workspaces.ts | 59 + config/CursorSync.md | 67 + docker/docker-compose.coolify.yml | 119 + docs/Glossary.md | 60 + docs/templates/epic-template.md | 47 + docs/templates/plan-template.md | 49 + docs/templates/task-template.md | 47 + .../database/migrations/0000_nervous_ogun.sql | 168 ++ .../migrations/0001_parched_red_hulk.sql | 16 + .../0002_markdown_backlog_cursor_sync.sql | 88 + .../migrations/meta/0000_snapshot.json | 1436 ++++++++++ .../migrations/meta/0001_snapshot.json | 1564 +++++++++++ .../migrations/meta/0002_snapshot.json | 2304 +++++++++++++++++ .../database/migrations/meta/_journal.json | 27 + packages/database/package.json | 10 +- .../database/src/markdown-backlog/index.ts | 10 + .../database/src/markdown-backlog/kinds.ts | 6 + .../database/src/markdown-backlog/parse.ts | 103 + .../database/src/markdown-backlog/paths.ts | 67 + .../database/src/markdown-backlog/sync.ts | 153 ++ packages/database/src/schema/cursor_sync.ts | 41 + packages/database/src/schema/favorites.ts | 16 + packages/database/src/schema/forms.ts | 55 + packages/database/src/schema/index.ts | 5 + .../database/src/schema/markdown_backlog.ts | 60 + packages/database/src/schema/relations.ts | 42 + packages/database/src/schema/types.ts | 32 + .../src/scripts/watch-markdown-backlog.ts | 68 + packages/shared/src/types/forms.ts | 59 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/objects.ts | 15 + .../Epic-cursor-sync-layer.md | 51 + .../Task-mapping-model-and-migration.md | 49 + .../Task-push-pull-job-interface.md | 49 + .../Epic-markdown-backlog.md | 51 + .../Task-folder-layout-and-templates.md | 49 + .../Task-parser-requirements.md | 54 + .../Epic-multitenancy/Epic-multitenancy.md | 50 + .../Task-tenant-scoped-cursor-connections.md | 49 + .../Plan-multitenant-cursor-sync.md | 70 + plans/README.md | 25 + pnpm-lock.yaml | 62 +- 108 files changed, 14142 insertions(+), 1036 deletions(-) create mode 100644 apps/web/app/(app)/[workspaceSlug]/ai/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/docs/[docId]/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/forms/[formId]/edit/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/forms/[formId]/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/forms/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/planner/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/settings/templates/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/settings/types/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/teams/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/whiteboards/[whiteboardId]/page.tsx create mode 100644 apps/web/components/forms/form-builder.tsx create mode 100644 apps/web/components/forms/form-field-card.tsx create mode 100644 apps/web/components/forms/form-field-config.tsx create mode 100644 apps/web/components/forms/form-field-type-picker.tsx create mode 100644 apps/web/components/forms/form-mapping-picker.tsx create mode 100644 apps/web/components/forms/form-renderer.tsx create mode 100644 apps/web/components/forms/form-responses.tsx create mode 100644 apps/web/components/forms/index.ts create mode 100644 apps/web/components/layout/icon-rail.tsx create mode 100644 apps/web/components/layout/top-header.tsx create mode 100644 apps/web/components/objects/create-object-dialog.tsx create mode 100644 apps/web/components/objects/index.ts create mode 100644 apps/web/components/types/index.ts create mode 100644 apps/web/components/types/type-editor.tsx create mode 100644 apps/web/components/types/type-manager.tsx create mode 100644 apps/web/components/types/type-picker.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/views/form/form-view.tsx create mode 100644 apps/web/components/views/overview/overview-view.tsx create mode 100644 apps/web/server/routers/favorites.ts create mode 100644 apps/web/server/routers/forms.ts create mode 100644 apps/web/server/routers/types.ts create mode 100644 apps/web/server/routers/workspaces.ts create mode 100644 config/CursorSync.md create mode 100644 docker/docker-compose.coolify.yml create mode 100644 docs/Glossary.md create mode 100644 docs/templates/epic-template.md create mode 100644 docs/templates/plan-template.md create mode 100644 docs/templates/task-template.md create mode 100644 packages/database/migrations/0000_nervous_ogun.sql create mode 100644 packages/database/migrations/0001_parched_red_hulk.sql create mode 100644 packages/database/migrations/0002_markdown_backlog_cursor_sync.sql create mode 100644 packages/database/migrations/meta/0000_snapshot.json create mode 100644 packages/database/migrations/meta/0001_snapshot.json create mode 100644 packages/database/migrations/meta/0002_snapshot.json create mode 100644 packages/database/migrations/meta/_journal.json create mode 100644 packages/database/src/markdown-backlog/index.ts create mode 100644 packages/database/src/markdown-backlog/kinds.ts create mode 100644 packages/database/src/markdown-backlog/parse.ts create mode 100644 packages/database/src/markdown-backlog/paths.ts create mode 100644 packages/database/src/markdown-backlog/sync.ts create mode 100644 packages/database/src/schema/cursor_sync.ts create mode 100644 packages/database/src/schema/favorites.ts create mode 100644 packages/database/src/schema/forms.ts create mode 100644 packages/database/src/schema/markdown_backlog.ts create mode 100644 packages/database/src/schema/types.ts create mode 100644 packages/database/src/scripts/watch-markdown-backlog.ts create mode 100644 packages/shared/src/types/forms.ts create mode 100644 plans/Plan-multitenant-cursor-sync/Epic-cursor-sync-layer/Epic-cursor-sync-layer.md create mode 100644 plans/Plan-multitenant-cursor-sync/Epic-cursor-sync-layer/Task-mapping-model-and-migration.md create mode 100644 plans/Plan-multitenant-cursor-sync/Epic-cursor-sync-layer/Task-push-pull-job-interface.md create mode 100644 plans/Plan-multitenant-cursor-sync/Epic-markdown-backlog/Epic-markdown-backlog.md create mode 100644 plans/Plan-multitenant-cursor-sync/Epic-markdown-backlog/Task-folder-layout-and-templates.md create mode 100644 plans/Plan-multitenant-cursor-sync/Epic-markdown-backlog/Task-parser-requirements.md create mode 100644 plans/Plan-multitenant-cursor-sync/Epic-multitenancy/Epic-multitenancy.md create mode 100644 plans/Plan-multitenant-cursor-sync/Epic-multitenancy/Task-tenant-scoped-cursor-connections.md create mode 100644 plans/Plan-multitenant-cursor-sync/Plan-multitenant-cursor-sync.md create mode 100644 plans/README.md diff --git a/.env.example b/.env.example index 888fe92..c9c03df 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,82 @@ -# Database -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/tasks" +# ===================================================================== +# ECHODO — Environment Variables +# ===================================================================== +# Local dev: copy this file to .env and adjust values. +# Coolify (CT 107): paste these into the resource's "Environment Variables" +# tab. Coolify also accepts a .env file uploaded via the UI. +# +# See AGENT-DEPLOY.md for shared CT 102 service credentials and the +# CT 100 Traefik routing pattern. +# ===================================================================== -# Auth (NextAuth) +# ----- Database (CT 102 shared Postgres) ----- +# Local dev: postgresql://postgres:postgres@localhost:5432/tasks +# Production: dedicated `echodo` user/db on CT 102 +DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo" + +# ----- Redis (CT 102 shared Redis) ----- +# Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11) +# Local dev: redis://localhost:6379 +REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11" + +# ----- Auth (NextAuth v5) ----- +# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud NEXTAUTH_URL="http://localhost:3000" -NEXTAUTH_SECRET="your-secret-here-generate-with-openssl-rand-base64-32" +# Generate with: openssl rand -base64 32 +NEXTAUTH_SECRET="CHANGE_ME_RUN_openssl_rand_base64_32" +AUTH_SECRET="CHANGE_ME_RUN_openssl_rand_base64_32" -# OAuth Providers (optional) -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" -GOOGLE_CLIENT_ID="" -GOOGLE_CLIENT_SECRET="" +# Optional dev-only credentials provider (looks up users by email, +# accepts this single password for everyone). Leave blank in prod. +AUTH_DEV_PASSWORD="" -# Redis -REDIS_URL="redis://localhost:6379" +# ----- OAuth Providers (all optional) ----- +AUTH_GITHUB_ID="" +AUTH_GITHUB_SECRET="" +AUTH_GOOGLE_ID="" +AUTH_GOOGLE_SECRET="" -# Collaboration Server (browser / clients) +# Authentik SSO (CT 100 — auth.stillwell.cloud) +# In Authentik admin → Applications → Create OAuth2/OpenID Provider. +# Slug "echodo", redirect URI: /api/auth/callback/authentik +AUTH_AUTHENTIK_ID="" +AUTH_AUTHENTIK_SECRET="" +AUTH_AUTHENTIK_ISSUER="https://auth.stillwell.cloud/application/o/echodo/" + +# ----- Realtime collab server (Hocuspocus / Yjs) ----- +# Server-side reference (used by Next.js server actions, if any) COLLAB_SERVER_URL="ws://localhost:1234" +# Browser-facing — must be wss:// in production because the page is https +NEXT_PUBLIC_COLLAB_SERVER_URL="ws://localhost:1234" -# AI Providers +# ----- Umami Analytics (CT 107 — analytics.stillwell.cloud) ----- +# In Umami: Settings → Websites → Add website. Copy the website UUID. +NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js" +NEXT_PUBLIC_UMAMI_WEBSITE_ID="" + +# ----- Libredesk Support Widget (CT 105 — support.stillwell.cloud) ----- +NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud" +NEXT_PUBLIC_LIBREDESK_WIDGET_ID="" + +# ----- Directus Feedback (CT 107 — directus.stillwell.cloud) ----- +# Static token scoped write-only on the `feedback` collection +NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud" +DIRECTUS_FEEDBACK_TOKEN="" + +# ----- AI Providers ----- +# Use OPENAI_BASE_URL=http://192.168.68.108:11434/v1 to route through +# local Ollama (CT 108) instead of the OpenAI API. OPENAI_API_KEY="" +OPENAI_BASE_URL="" ANTHROPIC_API_KEY="" -# MCP Server (stdio transport today; port reserved for future HTTP/SSE) +# ----- MCP Server ----- MCP_SERVER_PORT=3001 -# --- Docker Compose (optional; copy to .env and adjust) --- -# When all app services run in Docker, use service hostnames: -# DATABASE_URL="postgresql://postgres:postgres@postgres:5432/tasks" -# REDIS_URL="redis://redis:6379" -# COLLAB_SERVER_URL="ws://localhost:1234" -# (Browsers still reach the collab service via the published host port.) - +# ===================================================================== +# Local Docker Compose only (docker/docker-compose.yml) +# Not needed for Coolify (Coolify uses CT 102's shared Postgres/Redis). +# ===================================================================== POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=tasks diff --git a/.gitignore b/.gitignore index 2ac5fd4..52fbf2e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,24 @@ dist/ .env .env.local .env.*.local +.env.* +!.env.example + +# homelab / deployment secrets +AGENT-DEPLOY.md +*.secrets +*.secret +secrets/ +*-credentials.json +*-credentials.yaml +*-credentials.yml +credentials.json +credentials.yaml +credentials.yml +id_rsa +id_ed25519 +*.key +*.crt # debug npm-debug.log* diff --git a/apps/collab-server/src/index.ts b/apps/collab-server/src/index.ts index 5fa9f72..c5b5b5f 100644 --- a/apps/collab-server/src/index.ts +++ b/apps/collab-server/src/index.ts @@ -185,6 +185,25 @@ function buildDatabaseExtension() { }); } +const CURSOR_COLORS = [ + "#958DF1", + "#F98181", + "#FBBC88", + "#FAF594", + "#70CFF8", + "#94FADB", + "#B9F18D", + "#E8A0BF", +]; + +function colorFromName(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; +} + async function main() { const port = Number(process.env.PORT) || 1234; @@ -206,14 +225,46 @@ async function main() { throw Forbidden; } + // Try JWT decode for real user identity + const authSecret = process.env.AUTH_SECRET; + if (authSecret) { + try { + // Simple JWT decode (base64url decode the payload) + const parts = token.split("."); + if (parts.length === 3) { + const payload = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ); + if (payload.sub || payload.name) { + return { + user: { + id: payload.sub ?? payload.email ?? token.slice(0, 16), + name: payload.name ?? payload.email ?? "Anonymous", + color: colorFromName( + payload.name ?? payload.email ?? "User", + ), + }, + }; + } + } + } catch { + // Fall through to hash-based identity + } + } + const id = createHash("sha256").update(token).digest("hex"); return { - user: { id, name: `User ${id.slice(0, 8)}` }, + user: { + id, + name: `User ${id.slice(0, 8)}`, + color: colorFromName(id), + }, }; }, - async onConnect({ documentName }) { - console.log(`[collab] connect document=${documentName}`); + async onConnect({ documentName, connection }) { + const user = connection.readOnly ? "read-only" : "editor"; + console.log(`[collab] connect document=${documentName} (${user})`); }, async onDisconnect({ documentName }) { diff --git a/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx b/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx index c5e50b6..dbd5226 100644 --- a/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx @@ -1,13 +1,20 @@ "use client"; import type { ReactNode } from "react"; +import { useParams } from "next/navigation"; import { ViewToolbar } from "@/components/views/config"; import { useViewStore } from "@/lib/stores/view-store"; import { useViewData } from "@/lib/hooks/use-view-data"; export default function ProjectLayout({ children }: { children: ReactNode }) { + const params = useParams(); + const workspaceId = + typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; + const projectId = + typeof params?.projectId === "string" ? params.projectId : null; + const config = useViewStore((s) => s.config); - const { total } = useViewData(config); + const { total } = useViewData(config, workspaceId, projectId); return (
diff --git a/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx index d0490a7..d2a8d70 100644 --- a/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx @@ -1,12 +1,22 @@ "use client"; +import { useParams } from "next/navigation"; + import { useViewStore } from "@/lib/stores/view-store"; import { ListView } from "@/components/views/list"; import { BoardView } from "@/components/views/board"; import { TableView } from "@/components/views/table"; import { EmbedView } from "@/components/views/embed"; +import { OverviewView } from "@/components/views/overview/overview-view"; +import { FormView } from "@/components/views/form/form-view"; export default function ProjectPage() { + const params = useParams(); + const workspaceSlug = + typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; + const projectId = + typeof params?.projectId === "string" ? params.projectId : undefined; + const activeView = useViewStore((s) => s.activeView); const config = useViewStore((s) => s.config); @@ -17,6 +27,12 @@ export default function ProjectPage() { return ; case "embed": return ; + case "overview": + return ( + + ); + case "form": + return ; case "list": default: return ; diff --git a/apps/web/app/(app)/[workspaceSlug]/ai/page.tsx b/apps/web/app/(app)/[workspaceSlug]/ai/page.tsx new file mode 100644 index 0000000..bdc0759 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/ai/page.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useParams } from "next/navigation"; +import { Loader2, Send, Sparkles, Bot, User } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type Message = { + id: string; + role: "user" | "assistant"; + content: string; +}; + +export default function AIPage() { + const params = useParams(); + const workspaceSlug = params.workspaceSlug as string; + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + // Auto-scroll on new messages + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = input.trim(); + if (!trimmed || isLoading) return; + + const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: trimmed }; + setMessages((prev) => [...prev, userMsg]); + setInput(""); + setIsLoading(true); + + // Placeholder AI response + setTimeout(() => { + const assistantMsg: Message = { + id: crypto.randomUUID(), + role: "assistant", + content: + "I'm your AI assistant. Full AI integration is coming soon! I'll be able to help you create tasks, documents, and manage your workspace.", + }; + setMessages((prev) => [...prev, assistantMsg]); + setIsLoading(false); + }, 1000); + }; + + return ( +
+ {/* Header */} +
+
+ +
+
+

AI Assistant

+

Ask me anything about your workspace

+
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+
+ +
+
+

How can I help you today?

+

+ Ask me to create tasks, summarize documents, or manage your workspace. +

+
+
+ {["Create a new project", "Summarize my tasks", "Help me plan a sprint"].map( + (suggestion) => ( + + ), + )} +
+
+ ) : ( +
+ {messages.map((msg) => ( +
+
+ {msg.role === "user" ? : } +
+
+ {msg.content} +
+
+ ))} + {isLoading && ( +
+
+ +
+
+ +
+
+ )} +
+ )} +
+ + {/* Input */} +
+
+