Product identity editor (Slice 2): author a product's PRODUCT.md in-app
Each product on the Structure page gets an "Identity" action that opens the Markdown
editor (Edit/Preview, frontmatter-aware) wired to GET/PUT /products/{id}/identity, with
a starter PRODUCT.md template. Adds api.put. Saving makes the brief shared by every
agent across the product's teams (injected by the assembler from Slice 1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ async function request<T>(method: string, url: string, body?: unknown): Promise<
|
|||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(url: string) => request<T>('GET', url),
|
get: <T>(url: string) => request<T>('GET', url),
|
||||||
post: <T>(url: string, body?: unknown) => request<T>('POST', url, body),
|
post: <T>(url: string, body?: unknown) => request<T>('POST', url, body),
|
||||||
|
put: <T>(url: string, body?: unknown) => request<T>('PUT', url, body),
|
||||||
patch: <T>(url: string, body?: unknown) => request<T>('PATCH', url, body),
|
patch: <T>(url: string, body?: unknown) => request<T>('PATCH', url, body),
|
||||||
del: <T>(url: string) => request<T>('DELETE', url),
|
del: <T>(url: string) => request<T>('DELETE', url),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Boxes, Plus } from 'lucide-react'
|
import { Boxes, FileText, Plus } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AppShell } from '@/components/AppShell'
|
import { AppShell } from '@/components/AppShell'
|
||||||
|
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -15,9 +16,26 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
|
// A starter PRODUCT.md so an empty product gets useful structure to fill in.
|
||||||
|
const IDENTITY_TEMPLATE = (name: string) =>
|
||||||
|
`---
|
||||||
|
product: ${name}
|
||||||
|
goals:
|
||||||
|
domain:
|
||||||
|
conventions:
|
||||||
|
glossary:
|
||||||
|
---
|
||||||
|
|
||||||
|
# About ${name}
|
||||||
|
|
||||||
|
Describe what this product is, who it serves, and the conventions every agent on it should follow.
|
||||||
|
This identity is shared by every agent across the product's teams.
|
||||||
|
`
|
||||||
|
|
||||||
interface Division {
|
interface Division {
|
||||||
id: string
|
id: string
|
||||||
organizationId: string
|
organizationId: string
|
||||||
@@ -52,6 +70,8 @@ export function StructurePage() {
|
|||||||
const [divisionName, setDivisionName] = useState('')
|
const [divisionName, setDivisionName] = useState('')
|
||||||
const [product, setProduct] = useState({ name: '', kind: 'Product', divisionId: NONE })
|
const [product, setProduct] = useState({ name: '', kind: 'Product', divisionId: NONE })
|
||||||
const [team, setTeam] = useState({ name: '', productId: NONE })
|
const [team, setTeam] = useState({ name: '', productId: NONE })
|
||||||
|
const [identity, setIdentity] = useState<{ productId: string; name: string; content: string } | null>(null)
|
||||||
|
const [savingIdentity, setSavingIdentity] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!organizationId) return
|
if (!organizationId) return
|
||||||
@@ -115,6 +135,30 @@ export function StructurePage() {
|
|||||||
toast.success('Team created.')
|
toast.success('Team created.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Open the product's shared identity (PRODUCT.md) — load current text, or start from the template.
|
||||||
|
const openIdentity = async (p: Product) => {
|
||||||
|
try {
|
||||||
|
const current = await api.get<{ identity: string | null }>(`/api/orgboard/products/${p.id}/identity`)
|
||||||
|
setIdentity({ productId: p.id, name: p.name, content: current.identity ?? IDENTITY_TEMPLATE(p.name) })
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveIdentity = async () => {
|
||||||
|
if (!identity) return
|
||||||
|
setSavingIdentity(true)
|
||||||
|
try {
|
||||||
|
await api.put(`/api/orgboard/products/${identity.productId}/identity`, { identity: identity.content })
|
||||||
|
toast.success(`Identity saved for ${identity.name} — every agent on it now shares it.`)
|
||||||
|
setIdentity(null)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setSavingIdentity(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="mx-auto max-w-4xl p-6">
|
<div className="mx-auto max-w-4xl p-6">
|
||||||
@@ -197,6 +241,9 @@ export function StructurePage() {
|
|||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{p.divisionId ? divisions.find((d) => d.id === p.divisionId)?.name ?? '' : 'no division'}
|
{p.divisionId ? divisions.find((d) => d.id === p.divisionId)?.name ?? '' : 'no division'}
|
||||||
</span>
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => openIdentity(p)}>
|
||||||
|
<FileText data-icon="inline-start" /> Identity
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{products.length === 0 && <p className="text-sm text-muted-foreground">No products yet.</p>}
|
{products.length === 0 && <p className="text-sm text-muted-foreground">No products yet.</p>}
|
||||||
@@ -246,6 +293,33 @@ export function StructurePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{identity && (
|
||||||
|
<Sheet open onOpenChange={(o) => !o && setIdentity(null)}>
|
||||||
|
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-2xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Product identity — {identity.name}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
A shared PRODUCT.md (goals, domain, conventions) injected into every agent run on this
|
||||||
|
product, across all its teams. Treated as data, never as instructions.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex flex-col gap-4 px-4 pb-6">
|
||||||
|
<MarkdownEditor
|
||||||
|
rows={22}
|
||||||
|
mono
|
||||||
|
frontmatter
|
||||||
|
value={identity.content}
|
||||||
|
onChange={(content) => setIdentity({ ...identity, content })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setIdentity(null)}>Cancel</Button>
|
||||||
|
<Button disabled={savingIdentity} onClick={saveIdentity}>Save identity</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user