Fix: child tasks only from spec/breakdown agents; live review badge
Two fixes from real usage: - Child-task creation is now gated to story-producing skills (spec-writing, story-breakdown). A code/design/test agent's output is the artifact — a numbered list in it (e.g. file names from an engineer) is no longer mistaken for child stories. - The review-inbox badge now updates without a refresh: it polls more often (6s), refetches on window focus, and reacts to a REVIEWS_CHANGED event the board fires after Run (with a couple of delayed pulses to catch the ~5s completion) and the review page fires after approve / send back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,11 +25,17 @@ import { api } from '@/lib/api'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
/** Polls the count of held actions awaiting review, so the nav can flag work is waiting. */
|
/** Event other pages fire after dispatching a run or deciding a review, so the badge updates at once. */
|
||||||
|
export const REVIEWS_CHANGED = 'teamup:reviews-changed'
|
||||||
|
|
||||||
|
/** Tracks the count of held actions awaiting review: polls, and refetches on focus / a change event. */
|
||||||
function usePendingReviewCount(organizationId: string | null): number {
|
function usePendingReviewCount(organizationId: string | null): number {
|
||||||
const [count, setCount] = useState(0)
|
const [count, setCount] = useState(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!organizationId) return
|
if (!organizationId) {
|
||||||
|
setCount(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -40,10 +46,17 @@ function usePendingReviewCount(organizationId: string | null): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
void tick()
|
void tick()
|
||||||
const id = setInterval(tick, 12000)
|
const id = setInterval(tick, 6000)
|
||||||
|
const refetch = () => void tick()
|
||||||
|
window.addEventListener(REVIEWS_CHANGED, refetch)
|
||||||
|
window.addEventListener('focus', refetch)
|
||||||
|
document.addEventListener('visibilitychange', refetch)
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
clearInterval(id)
|
clearInterval(id)
|
||||||
|
window.removeEventListener(REVIEWS_CHANGED, refetch)
|
||||||
|
window.removeEventListener('focus', refetch)
|
||||||
|
document.removeEventListener('visibilitychange', refetch)
|
||||||
}
|
}
|
||||||
}, [organizationId])
|
}, [organizationId])
|
||||||
return count
|
return count
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import { Bot, Plus, Trash2 } from 'lucide-react'
|
import { Bot, Plus, Trash2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AppShell } from '@/components/AppShell'
|
import { AppShell, REVIEWS_CHANGED } from '@/components/AppShell'
|
||||||
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 {
|
import {
|
||||||
@@ -514,8 +514,13 @@ function TaskDrawer({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
act(
|
act(
|
||||||
() => api.post('/api/assembler/runs', { seatId, workItemId: task.id }),
|
() => api.post('/api/assembler/runs', { seatId, workItemId: task.id }),
|
||||||
'Dispatched — the proposal will land in the review inbox.',
|
'Dispatched — it will appear in the review inbox shortly.',
|
||||||
)
|
).then(() => {
|
||||||
|
// The proposal lands ~a few seconds later; nudge the review badge to refetch.
|
||||||
|
window.dispatchEvent(new Event(REVIEWS_CHANGED))
|
||||||
|
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 4000)
|
||||||
|
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 9000)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Bot data-icon="inline-start" />
|
<Bot data-icon="inline-start" />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { REVIEWS_CHANGED } from '@/components/AppShell'
|
||||||
import { AgentFace } from '@/components/AgentFace'
|
import { AgentFace } from '@/components/AgentFace'
|
||||||
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@@ -169,6 +170,7 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
|
|||||||
toast.info('Sent back to the agent')
|
toast.info('Sent back to the agent')
|
||||||
}
|
}
|
||||||
onDecided(item.id)
|
onDecided(item.id)
|
||||||
|
window.dispatchEvent(new Event(REVIEWS_CHANGED))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message)
|
toast.error((err as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -94,12 +94,20 @@ internal sealed class AgentRunExecutor(
|
|||||||
run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
|
run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Only a spec / story-breakdown agent proposes child stories. Other actions (code, design,
|
||||||
|
// tests) produce an artifact, not a backlog — so don't mistake a numbered list in their
|
||||||
|
// output (e.g. a list of file names) for child tasks.
|
||||||
|
var primarySkill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null;
|
||||||
|
var childTitles = primarySkill is "spec-writing" or "story-breakdown"
|
||||||
|
? OutputParser.ExtractChildTitles(output)
|
||||||
|
: Array.Empty<string>();
|
||||||
|
|
||||||
// Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review.
|
// Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review.
|
||||||
var gate = await actionGate.EvaluateAsync(
|
var gate = await actionGate.EvaluateAsync(
|
||||||
new AgentActionProposal(
|
new AgentActionProposal(
|
||||||
run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId,
|
run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId,
|
||||||
context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk,
|
context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk,
|
||||||
context.TaskTitle, output, OutputParser.ExtractChildTitles(output), assembled.Trace),
|
context.TaskTitle, output, childTitles, assembled.Trace),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Run {RunId}: {Action} ({Risk}) → {Outcome}.",
|
"Run {RunId}: {Action} ({Risk}) → {Outcome}.",
|
||||||
|
|||||||
Reference in New Issue
Block a user