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:
parent
c6e7757f42
commit
db08b0ae9e
9 changed files with 102 additions and 11 deletions
|
|
@ -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}`),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)' }}>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue