feat: enhance team provisioning instructions and context handling

- Added clarification on handling review requests in TeamProvisioningService to prevent redundant notifications.
- Introduced a new utility function to compute remaining context in contextMath, improving context management.
- Updated ChatHistory component to display remaining context urgency and percentage.
- Enhanced UserChatGroup with improved expand/collapse functionality and sticky "Show less" button.
- Updated tests to validate new context handling and messaging instructions.
This commit is contained in:
iliya 2026-03-10 13:34:10 +02:00
parent c6e7757f42
commit db08b0ae9e
9 changed files with 102 additions and 11 deletions

View file

@ -508,6 +508,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
``,
`Notification policy:`,
`- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`,
`- Review requests are also handled by the board runtime: review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request unless you are adding materially new context that is not already on the task.`,
``,
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag and wakes the owner) or SendMessage.`,
@ -2706,6 +2707,8 @@ export class TeamProvisioningService {
[
`Use the SendMessage tool with recipient="${memberName}".`,
`Forward each inbox item below as a teammate message, preserving task IDs and critical instructions.`,
`If an inbox item is marked Source: system_notification, treat it as an automated runtime notification.`,
`Forward that automated notification exactly once; do NOT send an additional paraphrased/manual follow-up for the same assignment/review/comment in this relay turn unless you truly need extra non-redundant context.`,
`Do NOT send any message to recipient "user" for this relay turn.`,
`Do NOT add extra narration outside the SendMessage calls.`,
].join('\n')
@ -2733,6 +2736,9 @@ export class TeamProvisioningService {
`${idx + 1}) From: ${m.from || 'unknown'}`,
` Timestamp: ${m.timestamp}`,
...(summaryLine ? [` ${summaryLine}`] : []),
...(typeof m.source === 'string' && m.source.trim()
? [` Source: ${m.source.trim()}`]
: []),
...replyInstructions,
` Text:`,
...m.text.split('\n').map((line) => ` ${line}`),

View file

@ -16,7 +16,11 @@ const SCROLL_THRESHOLD = 300;
/** Must match the `w-80` (320px) context panel width used in the layout below. */
const CONTEXT_PANEL_WIDTH_PX = 320;
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
import {
computeRemainingContext,
formatPercentOfTotal,
sumContextInjectionTokens,
} from '@renderer/utils/contextMath';
import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
import { ChatHistoryItem } from './ChatHistoryItem';
@ -197,6 +201,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens);
}, [allContextInjections, lastAiGroupTotalTokens]);
const remainingContext = useMemo(
() => computeRemainingContext(lastAiGroupTotalTokens),
[lastAiGroupTotalTokens]
);
// State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel)
const [isNavigationHighlight, setIsNavigationHighlight] = useState(false);
const navigationHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -835,7 +844,23 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
: 'var(--color-text-secondary)',
}}
>
{visibleContextPercentLabel ?? `Context (${allContextInjections.length})`}
{visibleContextPercentLabel ? (
<>
{visibleContextPercentLabel}
{remainingContext && remainingContext.urgency !== 'normal' && (
<span
style={{
color: remainingContext.urgency === 'critical' ? '#ef4444' : '#f59e0b',
}}
>
{' '}
({remainingContext.remainingPct.toFixed(0)}% left)
</span>
)}
</>
) : (
`Context (${allContextInjections.length})`
)}
</button>
</div>
)}

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
import { api } from '@renderer/api';
@ -13,7 +13,7 @@ import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger';
import { format } from 'date-fns';
import { User } from 'lucide-react';
import { ChevronDown, ChevronUp, User } from 'lucide-react';
import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';
@ -479,6 +479,12 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
// Combined expansion state: manual toggle or auto-expand for search
const isExpanded = isManuallyExpanded || shouldAutoExpand;
const anchorRef = useRef<HTMLDivElement>(null);
const handleCollapse = useCallback(() => {
setIsManuallyExpanded(false);
anchorRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, []);
// Determine display text
const baseDisplayText = isLongContent && !isExpanded ? stripped.slice(0, 500) + '...' : stripped;
@ -489,7 +495,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
);
return (
<div className="flex justify-end">
<div ref={anchorRef} className="flex justify-end">
<div className="max-w-[85%] space-y-2">
{/* Header - right aligned with improved hierarchy */}
<div className="flex items-center justify-end gap-1.5">
@ -524,18 +530,36 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
{displayText}
</ReactMarkdown>
</div>
{isLongContent && (
{isLongContent && !isExpanded && (
<button
onClick={() => setIsManuallyExpanded(!isManuallyExpanded)}
className="mt-2 text-xs underline hover:opacity-80"
onClick={() => setIsManuallyExpanded(true)}
className="mt-2 flex items-center gap-1 text-xs hover:opacity-80"
style={{ color: 'var(--color-text-muted)' }}
>
{isExpanded ? 'Show less' : 'Show more'}
<ChevronDown size={12} />
Show more
</button>
)}
</div>
)}
{/* Sticky Show less — outside overflow-hidden bubble so sticky works */}
{stripped && isLongContent && isExpanded ? (
<div className="sticky bottom-0 z-10 flex justify-center pb-1 pt-2">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
handleCollapse();
}}
>
<ChevronUp size={12} />
Show less
</button>
</div>
) : null}
{/* Images indicator */}
{hasImages && (
<div className="text-right text-xs" style={{ color: 'var(--color-text-muted)' }}>

View file

@ -425,7 +425,7 @@ export const ActivityItem = ({
return (
<article
className="group rounded-md [overflow:clip]"
className="group rounded-md"
style={{
marginLeft: isUserSent ? 15 : undefined,
backgroundColor:

View file

@ -97,7 +97,7 @@ export const AnimatedHeightReveal = ({
...style,
}}
>
<div style={{ minHeight: 0, overflow: 'hidden' }}>{children}</div>
<div style={{ minHeight: 0, overflow: isExpanded ? 'visible' : 'hidden' }}>{children}</div>
</div>
);
};

View file

@ -1,3 +1,5 @@
import { DEFAULT_CONTEXT_WINDOW } from '@shared/utils/modelParser';
import type { ContextInjection } from '@renderer/types/contextInjection';
export function sumContextInjectionTokens(injections: readonly ContextInjection[]): number {
@ -25,3 +27,25 @@ export function formatPercentOfTotal(
if (pct === null) return null;
return `${pct.toFixed(1)}% of input`;
}
export type ContextUrgency = 'normal' | 'warning' | 'critical';
export interface RemainingContext {
remainingPct: number;
urgency: ContextUrgency;
}
/**
* Compute how much context window remains before compaction.
* Returns null if input data is unavailable.
*/
export function computeRemainingContext(
totalInputTokens: number | undefined,
contextWindow: number = DEFAULT_CONTEXT_WINDOW
): RemainingContext | null {
if (totalInputTokens === undefined || totalInputTokens <= 0) return null;
const remainingPct = Math.max(((contextWindow - totalInputTokens) / contextWindow) * 100, 0);
const urgency: ContextUrgency =
remainingPct < 20 ? 'critical' : remainingPct < 40 ? 'warning' : 'normal';
return { remainingPct, urgency };
}

View file

@ -3,6 +3,9 @@
* Parses model identifiers into friendly display names and metadata.
*/
/** Default context window size for Claude models (all current models use 200K) */
export const DEFAULT_CONTEXT_WINDOW = 200_000;
/** Known model families with specific styling */
export type KnownModelFamily = 'sonnet' | 'opus' | 'haiku';

View file

@ -112,6 +112,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain('PROGRESS REPORTING (MANDATORY)');
expect(prompt).toContain('Never bulk-move many tasks at the end');
expect(prompt).toContain('Default to working ONE task at a time');
expect(prompt).toContain(
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
);
expect(prompt).toContain('task_start');
expect(prompt).toContain('task_complete');
expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
@ -180,6 +183,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain('Do NOT start the next task until the current task is completed');
expect(prompt).toContain('Do NOT delay this reconnect turn by reading internal config files');
expect(prompt).toContain('Treat it as a diagnostic cross-check, not as the first reconnect action.');
expect(prompt).toContain(
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
);
expect(prompt).toContain('task_start');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);

View file

@ -421,6 +421,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
read: false,
summary: 'Comment on #abcd1234',
messageId: 'm-alice-1',
source: 'system_notification',
},
]);
@ -432,6 +433,8 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('"type":"user"');
expect(payload).toContain('recipient=\\"alice\\"');
expect(payload).toContain('Source: system_notification');
expect(payload).toContain('Forward that automated notification exactly once;');
expect(payload).toContain('Please retry with logging enabled.');
});