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:`,
|
`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.`,
|
`- 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):`,
|
`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.`,
|
`- 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}".`,
|
`Use the SendMessage tool with recipient="${memberName}".`,
|
||||||
`Forward each inbox item below as a teammate message, preserving task IDs and critical instructions.`,
|
`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 send any message to recipient "user" for this relay turn.`,
|
||||||
`Do NOT add extra narration outside the SendMessage calls.`,
|
`Do NOT add extra narration outside the SendMessage calls.`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
@ -2733,6 +2736,9 @@ export class TeamProvisioningService {
|
||||||
`${idx + 1}) From: ${m.from || 'unknown'}`,
|
`${idx + 1}) From: ${m.from || 'unknown'}`,
|
||||||
` Timestamp: ${m.timestamp}`,
|
` Timestamp: ${m.timestamp}`,
|
||||||
...(summaryLine ? [` ${summaryLine}`] : []),
|
...(summaryLine ? [` ${summaryLine}`] : []),
|
||||||
|
...(typeof m.source === 'string' && m.source.trim()
|
||||||
|
? [` Source: ${m.source.trim()}`]
|
||||||
|
: []),
|
||||||
...replyInstructions,
|
...replyInstructions,
|
||||||
` Text:`,
|
` Text:`,
|
||||||
...m.text.split('\n').map((line) => ` ${line}`),
|
...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. */
|
/** Must match the `w-80` (320px) context panel width used in the layout below. */
|
||||||
const CONTEXT_PANEL_WIDTH_PX = 320;
|
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 { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
|
||||||
import { ChatHistoryItem } from './ChatHistoryItem';
|
import { ChatHistoryItem } from './ChatHistoryItem';
|
||||||
|
|
@ -197,6 +201,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens);
|
return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens);
|
||||||
}, [allContextInjections, lastAiGroupTotalTokens]);
|
}, [allContextInjections, lastAiGroupTotalTokens]);
|
||||||
|
|
||||||
|
const remainingContext = useMemo(
|
||||||
|
() => computeRemainingContext(lastAiGroupTotalTokens),
|
||||||
|
[lastAiGroupTotalTokens]
|
||||||
|
);
|
||||||
|
|
||||||
// State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel)
|
// State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel)
|
||||||
const [isNavigationHighlight, setIsNavigationHighlight] = useState(false);
|
const [isNavigationHighlight, setIsNavigationHighlight] = useState(false);
|
||||||
const navigationHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const navigationHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
@ -835,7 +844,23 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
: 'var(--color-text-secondary)',
|
: '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>
|
</button>
|
||||||
</div>
|
</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 ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
|
@ -13,7 +13,7 @@ import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { User } from 'lucide-react';
|
import { ChevronDown, ChevronUp, User } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
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
|
// Combined expansion state: manual toggle or auto-expand for search
|
||||||
const isExpanded = isManuallyExpanded || shouldAutoExpand;
|
const isExpanded = isManuallyExpanded || shouldAutoExpand;
|
||||||
|
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const handleCollapse = useCallback(() => {
|
||||||
|
setIsManuallyExpanded(false);
|
||||||
|
anchorRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Determine display text
|
// Determine display text
|
||||||
const baseDisplayText = isLongContent && !isExpanded ? stripped.slice(0, 500) + '...' : stripped;
|
const baseDisplayText = isLongContent && !isExpanded ? stripped.slice(0, 500) + '...' : stripped;
|
||||||
|
|
||||||
|
|
@ -489,7 +495,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div ref={anchorRef} className="flex justify-end">
|
||||||
<div className="max-w-[85%] space-y-2">
|
<div className="max-w-[85%] space-y-2">
|
||||||
{/* Header - right aligned with improved hierarchy */}
|
{/* Header - right aligned with improved hierarchy */}
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
|
@ -524,18 +530,36 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
{displayText}
|
{displayText}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
{isLongContent && (
|
{isLongContent && !isExpanded && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsManuallyExpanded(!isManuallyExpanded)}
|
onClick={() => setIsManuallyExpanded(true)}
|
||||||
className="mt-2 text-xs underline hover:opacity-80"
|
className="mt-2 flex items-center gap-1 text-xs hover:opacity-80"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
>
|
>
|
||||||
{isExpanded ? 'Show less' : 'Show more'}
|
<ChevronDown size={12} />
|
||||||
|
Show more
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Images indicator */}
|
||||||
{hasImages && (
|
{hasImages && (
|
||||||
<div className="text-right text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="text-right text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
|
|
||||||
|
|
@ -425,7 +425,7 @@ export const ActivityItem = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className="group rounded-md [overflow:clip]"
|
className="group rounded-md"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: isUserSent ? 15 : undefined,
|
marginLeft: isUserSent ? 15 : undefined,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export const AnimatedHeightReveal = ({
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minHeight: 0, overflow: 'hidden' }}>{children}</div>
|
<div style={{ minHeight: 0, overflow: isExpanded ? 'visible' : 'hidden' }}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { DEFAULT_CONTEXT_WINDOW } from '@shared/utils/modelParser';
|
||||||
|
|
||||||
import type { ContextInjection } from '@renderer/types/contextInjection';
|
import type { ContextInjection } from '@renderer/types/contextInjection';
|
||||||
|
|
||||||
export function sumContextInjectionTokens(injections: readonly ContextInjection[]): number {
|
export function sumContextInjectionTokens(injections: readonly ContextInjection[]): number {
|
||||||
|
|
@ -25,3 +27,25 @@ export function formatPercentOfTotal(
|
||||||
if (pct === null) return null;
|
if (pct === null) return null;
|
||||||
return `${pct.toFixed(1)}% of input`;
|
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.
|
* 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 */
|
/** Known model families with specific styling */
|
||||||
export type KnownModelFamily = 'sonnet' | 'opus' | 'haiku';
|
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('PROGRESS REPORTING (MANDATORY)');
|
||||||
expect(prompt).toContain('Never bulk-move many tasks at the end');
|
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('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_start');
|
||||||
expect(prompt).toContain('task_complete');
|
expect(prompt).toContain('task_complete');
|
||||||
expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
|
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 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('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('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('task_start');
|
||||||
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
|
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
|
||||||
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
read: false,
|
read: false,
|
||||||
summary: 'Comment on #abcd1234',
|
summary: 'Comment on #abcd1234',
|
||||||
messageId: 'm-alice-1',
|
messageId: 'm-alice-1',
|
||||||
|
source: 'system_notification',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -432,6 +433,8 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||||
expect(payload).toContain('"type":"user"');
|
expect(payload).toContain('"type":"user"');
|
||||||
expect(payload).toContain('recipient=\\"alice\\"');
|
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.');
|
expect(payload).toContain('Please retry with logging enabled.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue