Skip to content
Open
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
62 changes: 62 additions & 0 deletions .plans/create-bead-ui-convoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Create Bead UI Convoy — Shared Context

## Bead 1: Backend (createBead tRPC + Workers AI enrichment)

### Status: completed

### Deviations from plan

- `HELD_LABEL` and `HELD_LABEL_LIKE` constants are exported from `patrol.ts` (not alongside `TRIAGE_LABEL_LIKE` in reconciler.ts — that's just the consumer). This matches the existing pattern where `TRIAGE_LABEL_LIKE` is defined in `patrol.ts` and imported in `reconciler.ts`.
- Labels in the database are stored as JSON arrays (e.g. `'["gt:held","bug"]'`), so `HELD_LABEL_LIKE` uses the pattern `%"gt:held"%` (with quotes), matching the same pattern used by `TRIAGE_LABEL_LIKE = '%"gt:triage-request"%'`.
- `slingBead()` already supports `labels?: string[]` in its input type — no change needed there. The `sling` tRPC procedure needed updating to accept `labels` and pass it through.
- The `enrichBead` procedure uses `ctx.env.AI.run(...)`. The AI binding is typed as `Ai` (Cloudflare Workers AI SDK) in `worker-configuration.d.ts`. The `run()` call for text generation models returns `{ response?: string }`.
- `createHeldBead` and `notifyMayorOfNewBead` are added as public RPC methods on `TownDO` (not private helpers), since they need to be called from the tRPC router via `townStub`.
- For `startBead`, the labels are updated via `beadOps.updateBeadFields()` — filtering out `gt:held`. Then `escalateToActiveCadence()` is called to arm the alarm.

### Notes for future implementors

- The `createBead` tRPC procedure creates an `open` bead with `gt:held` label (unless `startImmediately=true`). The reconciler's Rule 1 excludes beads with `gt:held` label from dispatch.
- The `startBead` tRPC procedure removes `gt:held` from labels and arms the reconciler alarm via `townStub.startHeldBead()`.
- The `enrichBead` procedure calls Workers AI (`@cf/meta/llama-3.1-8b-instruct`) to suggest title + labels. It returns `null` if the AI response is unparseable.
- All three new tRPC procedures follow the `gastownProcedure` pattern with `verifyRigOwnership` (createBead, startBead) or `verifyTownOwnership` (enrichBead) for authorization.
- The mayor is notified via `townStub.notifyMayorOfNewBead()` when `startImmediately=false`.

---

## Bead 2: UI (CreateBeadDrawer + MDXEditor)

### Status: in progress

### Deviations from plan

- The `createBead`, `startBead`, and `enrichBead` procedures from bead 0/1 were NOT yet reflected in `apps/web/src/lib/gastown/types/router.d.ts`. Added them manually to the declaration file (both the top-level `gastawnRouter` section and the nested `wrappedGastawnRouter` section).
- Used `@mdxeditor/editor` with a dynamic import wrapper. The MDXEditor CSS import must be in the non-SSR wrapper file (not the parent component) to avoid Next.js SSR issues.
- MDXEditor dark theme: overriding via CSS variables on the container element is the cleanest approach since the library doesn't natively support dark mode out of the box.
- Debounce for AI enrichment is implemented with `useEffect` + `setTimeout`/`clearTimeout` rather than a library like `use-debounce`, since that would be a new dependency.

### Notes for future implementors

- MDXEditor requires `ssr: false` dynamic import in Next.js. The `MarkdownEditor.tsx` wrapper imports the CSS and renders MDXEditor directly; the parent uses `dynamic(() => import('./MarkdownEditor'), { ssr: false })`.
- The `CreateBeadDrawer` uses `vaul` `Drawer.Root` (same pattern as `BeadDetailDrawer`) for the right-side slide-in.
- AI enrichment fires on body text > 20 chars after a 1500ms debounce. `userEditedTitle` state prevents AI overwriting manual title changes.
- Labels from `enrichBead` are shown as `✨` chips. The user can remove them. Custom label entry via a text input "+ add" pattern.
- `startImmediately=false` (default): bead gets `gt:held` label and mayor is notified. `startImmediately=true`: bead is created and dispatched immediately.

---

## Bead 3: Mayor system prompt

### Status: completed

### Deviations from plan

- `notifyMayorOfNewBead()` was already partially implemented by bead 1 (with a slightly different message). Updated the message to match the spec exactly (adding the `To start the bead immediately, remove the gt:held label via gt_bead_update.` line and the `When you reply, create a message bead` instruction using the exact wording from the bead spec).
- The mayor system prompt lives in `services/gastown/src/prompts/mayor-system.prompt.ts` — a standalone file exporting `buildMayorSystemPrompt()`. Added the new `## User-Created Beads` section at the end, before the closing backtick.
- `gt_bead_update` in `mayor-tools.ts` already exposed both `labels: string[]` and `body: string` — no changes needed there.

### Notes for future implementors

- The mayor system prompt is in `services/gastown/src/prompts/mayor-system.prompt.ts`, NOT inline in `Town.do.ts`. It's called via `dispatch.systemPromptForRole()` which calls `buildMayorSystemPrompt()` from `container-dispatch.ts`.
- `sendMayorMessage()` in `Town.do.ts` sends a message to the mayor's running container via the kilo API. It's called from `notifyMayorOfNewBead()` after a user creates a held bead.
- `gt_bead_update` (mayor-tools.ts:294) already has both `labels` and `body` parameters — no changes needed.
- The mayor sees the notification message as a user turn. It should respond by calling `gt_sling({ type: 'message', parent_bead_id: beadId, body: '...' })` to post its response back to the bead drawer.
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@
"@chat-adapter/slack": "^4.20.1",
"@chat-adapter/state-memory": "^4.20.1",
"@chat-adapter/state-redis": "^4.20.1",
"google-auth-library": "^10.4.1",
"@kilocode/db": "workspace:*",
"@kilocode/encryption": "workspace:*",
"@kilocode/kiloclaw-secret-catalog": "workspace:*",
"@kilocode/worker-utils": "workspace:*",
"@lottiefiles/dotlottie-react": "^0.17.15",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@mdxeditor/editor": "^3.55.0",
"@mistralai/mistralai": "^1.15.1",
"@monaco-editor/react": "^4.7.0",
"@next/bundle-analyzer": "^16.1.6",
Expand Down Expand Up @@ -111,6 +111,7 @@
"eventsource-parser": "^3.0.6",
"fflate": "^0.8.2",
"form-data": "^4.0.5",
"google-auth-library": "^10.4.1",
"jotai": "^2.18.1",
"jotai-minidb": "^0.0.8",
"js-cookie": "^3.0.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { BeadBoard } from '@/components/gastown/BeadBoard';
import { AgentCard } from '@/components/gastown/AgentCard';
import { ConvoyTimeline } from '@/components/gastown/ConvoyTimeline';
import { SlingDialog } from '@/components/gastown/SlingDialog';
import { CreateBeadDrawer } from '@/components/gastown/CreateBeadDrawer';
import { useDrawerStack } from '@/components/gastown/DrawerStack';
import {
Plus,
Expand Down Expand Up @@ -39,7 +39,7 @@ export function RigDetailPageClient({
}: RigDetailPageClientProps) {
const townBasePath = basePathOverride ?? `/gastown/${townId}`;
const trpc = useGastownTRPC();
const [isSlingOpen, setIsSlingOpen] = useState(false);
const [isCreateBeadOpen, setIsCreateBeadOpen] = useState(false);
const [convoysCollapsed, setConvoysCollapsed] = useState(false);
const { open: openDrawer } = useDrawerStack();

Expand Down Expand Up @@ -71,6 +71,16 @@ export function RigDetailPageClient({
})
);

const startBead = useMutation(
trpc.gastown.startBead.mutationOptions({
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: trpc.gastown.listBeads.queryKey() });
toast.success('Bead started');
},
onError: err => toast.error(err.message),
})
);

const deleteAgent = useMutation(
trpc.gastown.deleteAgent.mutationOptions({
onSuccess: () => {
Expand Down Expand Up @@ -156,11 +166,11 @@ export function RigDetailPageClient({
<Button
variant="primary"
size="sm"
onClick={() => setIsSlingOpen(true)}
onClick={() => setIsCreateBeadOpen(true)}
className="gap-1.5 bg-[color:oklch(95%_0.15_108_/_0.90)] text-black hover:bg-[color:oklch(95%_0.15_108_/_0.95)]"
>
<Plus className="size-3.5" />
Sling Work
New Bead
</Button>
</div>
</div>
Expand Down Expand Up @@ -228,6 +238,7 @@ export function RigDetailPageClient({
}
}}
onSelectBead={bead => openDrawer({ type: 'bead', beadId: bead.bead_id, rigId })}
onStartBead={beadId => startBead.mutate({ rigId, beadId })}
agentNameById={agentNameById}
/>
</div>
Expand Down Expand Up @@ -285,7 +296,12 @@ export function RigDetailPageClient({
</div>
</div>

<SlingDialog rigId={rigId} isOpen={isSlingOpen} onClose={() => setIsSlingOpen(false)} />
<CreateBeadDrawer
rigId={rigId}
townId={townId}
isOpen={isCreateBeadOpen}
onClose={() => setIsCreateBeadOpen(false)}
/>
</div>
);
}
Expand Down
68 changes: 56 additions & 12 deletions apps/web/src/components/gastown/BeadBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { Trash2 } from 'lucide-react';
import { Trash2, Clock, Play } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { motion, AnimatePresence } from 'motion/react';

Expand All @@ -26,6 +26,7 @@ type BeadBoardProps = {
isLoading: boolean;
onDeleteBead?: (beadId: string) => void;
onSelectBead?: (bead: Bead) => void;
onStartBead?: (beadId: string) => void;
selectedBeadId?: string | null;
agentNameById?: Record<string, string>;
};
Expand Down Expand Up @@ -53,22 +54,31 @@ const priorityColors: Record<string, string> = {
critical: 'text-red-300',
};

const HELD_LABEL = 'gt:held';

function isHeld(bead: Bead) {
return bead.labels.includes(HELD_LABEL);
}

function BeadCard({
bead,
onDelete,
onSelect,
onStart,
isSelected,
agentNameById,
}: {
bead: Bead;
onDelete?: () => void;
onSelect?: () => void;
onStart?: () => void;
isSelected?: boolean;
agentNameById?: Record<string, string>;
}) {
const assigneeName = bead.assignee_agent_bead_id
? agentNameById?.[bead.assignee_agent_bead_id]
: null;
const held = isHeld(bead);

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!onSelect) return;
Expand All @@ -81,8 +91,9 @@ function BeadCard({
return (
<Card
className={cn(
'group border-white/10 bg-white/[0.03] transition-[border-color,background-color,transform] hover:bg-white/[0.05]',
'group relative border-white/10 bg-white/[0.03] transition-[border-color,background-color,transform] hover:bg-white/[0.05]',
onSelect ? 'cursor-pointer' : 'cursor-default',
held ? 'border-amber-500/20 bg-amber-500/[0.03]' : '',
isSelected
? 'border-[color:oklch(95%_0.15_108_/_0.45)] bg-[color:oklch(95%_0.15_108_/_0.06)]'
: ''
Expand All @@ -103,7 +114,14 @@ function BeadCard({
>
<CardContent className="p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<h4 className="line-clamp-2 text-sm font-medium text-white/90">{bead.title}</h4>
<div className="flex min-w-0 items-start gap-1.5">
{held && (
<span title="Held" className="mt-0.5 flex shrink-0">
<Clock className="size-3 text-amber-400/60" aria-label="Held" />
</span>
)}
<h4 className="line-clamp-2 text-sm font-medium text-white/90">{bead.title}</h4>
</div>
<div className="flex shrink-0 items-center gap-1">
<span className={cn('text-xs font-medium', priorityColors[bead.priority])}>
{bead.priority}
Expand All @@ -124,9 +142,16 @@ function BeadCard({
</div>

<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-xs">
{bead.type}
</Badge>
{held ? (
<span className="inline-flex items-center gap-1 rounded-md border border-amber-500/20 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400/80">
<Clock className="size-2.5" />
Held
</span>
) : (
<Badge variant="outline" className="text-xs">
{bead.type}
</Badge>
)}
<span className="text-xs text-white/50">
{formatDistanceToNow(new Date(bead.created_at), { addSuffix: true })}
</span>
Expand All @@ -137,17 +162,34 @@ function BeadCard({
)}
</div>

{bead.labels.length > 0 && (
{bead.labels.filter(l => l !== HELD_LABEL).length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{bead.labels.map(label => (
<Badge key={label} variant="secondary" className="text-xs">
{label}
</Badge>
))}
{bead.labels
.filter(l => l !== HELD_LABEL)
.map(label => (
<Badge key={label} variant="secondary" className="text-xs">
{label}
</Badge>
))}
</div>
)}
</CardContent>
</div>

{/* Hover quick action for held beads */}
{held && onStart && (
<button
type="button"
onClick={e => {
e.stopPropagation();
onStart();
}}
className="absolute right-2 bottom-2 flex items-center gap-1 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-2 py-1 text-[10px] font-medium text-emerald-400 opacity-0 transition-opacity hover:bg-emerald-500/20 group-hover:opacity-100"
>
<Play className="size-2.5" />
Start now
</button>
)}
</Card>
);
}
Expand All @@ -157,6 +199,7 @@ export function BeadBoard({
isLoading,
onDeleteBead,
onSelectBead,
onStartBead,
selectedBeadId,
agentNameById,
}: BeadBoardProps) {
Expand Down Expand Up @@ -235,6 +278,7 @@ export function BeadBoard({
bead={bead}
onDelete={onDeleteBead ? () => onDeleteBead(bead.bead_id) : undefined}
onSelect={onSelectBead ? () => onSelectBead(bead) : undefined}
onStart={onStartBead ? () => onStartBead(bead.bead_id) : undefined}
isSelected={selectedBeadId === bead.bead_id}
agentNameById={agentNameById}
/>
Expand Down
Loading