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
3 changes: 3 additions & 0 deletions docs/exec-plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@
| 文件 | 主题 | 完成日期 |
|------|------|----------|
| completed/engineering-quality-assurance.md | 工程质量保障体系(Harness Engineering)— 验证闭环、AI 文档、CDP、执行计划 | 2026-03-04 |
| completed/pr-169-context-ring-review-fixes.md | PR-169 Context Ring Review 修复 | 2026-03-10 |


42 changes: 42 additions & 0 deletions docs/exec-plans/completed/pr-169-context-ring-review-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# PR-169 Context Ring Review Fixes

> 创建时间:2026-03-10
> 最后更新:2026-03-10

## 状态

| Phase | 内容 | 状态 | 备注 |
|-------|------|------|------|
| Phase 0 | 读取 PR 评论与现状审查 | ✅ 已完成 | 已确认 owner review 7 项建议 |
| Phase 1 | Context Ring 组件化与逻辑下沉 | ✅ 已完成 | 抽离独立组件与纯函数,移除 MessageInput 内联大段逻辑 |
| Phase 2 | ChatView 上下文 token 计算优化 | ✅ 已完成 | 将 memo 触发条件改为轻量依赖,避免 messages 引用级重算 |
| Phase 3 | claude-client 类型与 lint 风险修复 | ✅ 已完成 | 恢复 sanitize 注释并重构 modelUsage contextWindow 提取 |
| Phase 4 | 单测与回归测试(npm run test) | ✅ 已完成 | typecheck + unit 全部通过 |

## 决策日志

- 2026-03-10: 采用“先修复 review 明确问题,再补测试并全量验证”的策略,减少行为变更范围。
- 2026-03-10: Context Ring 的计算逻辑提取为纯函数,便于单测并降低 MessageInput 复杂度。
- 2026-03-10: `npm run test` 初次失败原因为本地缺失依赖(`tsc` 不可用),通过 `npm install` 补齐后重跑通过。

## 详细设计

### 目标

- 消化 PR #169 已有 review 建议中的高优先级问题。
- 在不改变交互意图的前提下提升可维护性、类型安全与可测试性。

### 技术方案

1. 新增 `ContextUsageRing` 组件并替换 `MessageInput` 内联 IIFE。
2. 新增 `context-usage` 纯函数模块,承载 ratio/颜色/格式化/tooltip 文案计算。
3. `ChatView` 上下文 token 计算改为轻量依赖触发,减少不必要的重算。
4. `claude-client`:恢复 `sanitizeEnvValue` 的 lint 保护注释;重构 `modelUsage` 提取为显式解析函数。
5. 增加针对 Context Ring 纯函数的单元测试。

### 验收标准

- `MessageInput` 不再内联大段 Context Ring 渲染逻辑。
- Context Ring 颜色阈值不再硬编码在 JSX 内。
- `npm run test` 通过。
- PR 分支新增 commit,且仅包含上述修复。
4 changes: 3 additions & 1 deletion src/__tests__/unit/claude-session-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import assert from 'node:assert/strict';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { pathToFileURL } from 'url';

// We test the parser functions by creating temporary JSONL files
// that mimic Claude Code's session storage format.
Expand Down Expand Up @@ -115,6 +116,7 @@ function makeAssistantEntry(opts: {
// Since the project uses path aliases (@/), we import via a relative path
// that tsx can resolve with the project's tsconfig.
const parserPath = path.resolve(__dirname, '../../lib/claude-session-parser.ts');
const parserModuleUrl = pathToFileURL(parserPath).href;

describe('claude-session-parser', () => {
// We'll dynamically import the parser module
Expand All @@ -125,7 +127,7 @@ describe('claude-session-parser', () => {
process.env.HOME = TEST_DIR;

// Dynamic import - tsx handles the TypeScript + path alias resolution
parser = await import(parserPath);
parser = await import(parserModuleUrl);
});

after(() => {
Expand Down
40 changes: 40 additions & 0 deletions src/__tests__/unit/context-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { TranslationKey } from '../../i18n';
import {
CONTEXT_USAGE_COLORS,
formatTokenCount,
getContextUsageColor,
getContextUsageRatio,
getContextUsageTooltip,
} from '../../components/chat/context-usage';

describe('context-usage helpers', () => {
it('formats token counts for k/M thresholds', () => {
assert.equal(formatTokenCount(999), '999');
assert.equal(formatTokenCount(1500), '2k');
assert.equal(formatTokenCount(1250000), '1.3M');
});

it('computes usage ratio safely', () => {
assert.equal(getContextUsageRatio(1000, 200000, false), 0.005);
assert.equal(getContextUsageRatio(10, 0, false), 0);
assert.equal(getContextUsageRatio(10, 100, true), 0);
assert.equal(getContextUsageRatio(300, 200, false), 1);
});

it('picks color by compacting/stale/threshold priority', () => {
assert.equal(getContextUsageColor(0.9, true, false), CONTEXT_USAGE_COLORS.compacting);
assert.equal(getContextUsageColor(0.9, false, true), CONTEXT_USAGE_COLORS.stale);
assert.equal(getContextUsageColor(0.8, false, false), CONTEXT_USAGE_COLORS.danger);
assert.equal(getContextUsageColor(0.6, false, false), CONTEXT_USAGE_COLORS.warning);
assert.equal(getContextUsageColor(0.3, false, false), CONTEXT_USAGE_COLORS.safe);
});

it('builds tooltip text from mode and values', () => {
const t = (key: TranslationKey) => key;
assert.equal(getContextUsageTooltip(100, 200000, true, false, t), 'messageInput.compacting');
assert.equal(getContextUsageTooltip(100, 200000, false, true, t), 'messageInput.contextRefreshPending');
assert.equal(getContextUsageTooltip(1200, 200000, false, false, t), '1k / 200k');
});
});
39 changes: 38 additions & 1 deletion src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
Expand Down Expand Up @@ -59,6 +59,13 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const statusText = streamSnapshot?.statusText;
const pendingPermission = streamSnapshot?.pendingPermission ?? null;
const permissionResolved = streamSnapshot?.permissionResolved ?? null;
const snapshotContextWindow = streamSnapshot?.contextWindow;
const contextWindowMax = (snapshotContextWindow && snapshotContextWindow.total > 0)
? snapshotContextWindow.total
: 200000;
const snapshotIsCompacting = streamSnapshot?.isCompacting ?? false;
const snapshotStreamingContextTokens = streamSnapshot?.streamingContextTokens ?? null;
const snapshotContextTokensPendingRefresh = streamSnapshot?.contextTokensPendingRefresh ?? false;

// Pending image generation notices — flushed into the next user message so the LLM knows about generated images
const pendingImageNoticesRef = useRef<string[]>([]);
Expand Down Expand Up @@ -208,6 +215,31 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
stopStream(sessionId);
}, [sessionId]);

const contextTokens = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === 'assistant' && msg.token_usage) {
try {
const usage = typeof msg.token_usage === 'string'
? JSON.parse(msg.token_usage) : msg.token_usage;
if (usage.last_input_tokens) {
return (usage.last_input_tokens as number) + (usage.output_tokens || 0);
}
const input = (usage.input_tokens || 0)
+ (usage.cache_read_input_tokens || 0)
+ (usage.cache_creation_input_tokens || 0);
const output = usage.output_tokens || 0;
return input + output;
} catch { /* skip */ }
}
}
return 0;
}, [messages.length, messages[messages.length - 1]?.id, messages[messages.length - 1]?.token_usage]);

const effectiveContextTokens = snapshotStreamingContextTokens
? snapshotStreamingContextTokens.used
: contextTokens;

// Permission response — delegates to manager
const handlePermissionResponse = useCallback(
async (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>, denyMessage?: string) => {
Expand Down Expand Up @@ -420,7 +452,12 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
workingDirectory={workingDirectory}
mode={mode}
onModeChange={handleModeChange}
contextTokens={effectiveContextTokens}
contextStale={snapshotContextTokensPendingRefresh}
maxContext={contextWindowMax}
isCompacting={snapshotIsCompacting}
/>
</div>
);
}

61 changes: 61 additions & 0 deletions src/components/chat/ContextUsageRing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
DEFAULT_MAX_CONTEXT,
RING_CIRCUMFERENCE,
getContextUsageColor,
getContextUsageRatio,
getContextUsageTooltip,
} from './context-usage';
import type { TranslationKey } from '@/i18n';

interface ContextUsageRingProps {
contextTokens?: number;
contextStale?: boolean;
maxContext?: number;
isCompacting?: boolean;
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
}

export function ContextUsageRing({
contextTokens = 0,
contextStale = false,
maxContext = DEFAULT_MAX_CONTEXT,
isCompacting = false,
t,
}: ContextUsageRingProps) {
const isContextStale = contextStale && !isCompacting;
const ratio = getContextUsageRatio(contextTokens, maxContext, isContextStale);
const offset = RING_CIRCUMFERENCE * (1 - ratio);
const color = getContextUsageColor(ratio, isCompacting, isContextStale);
const tooltipText = getContextUsageTooltip(contextTokens, maxContext, isCompacting, isContextStale, t);

return (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-accent/50">
<svg width="20" height="20" viewBox="0 0 20 20" className="-rotate-90">
<circle cx="10" cy="10" r="8" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted-foreground/15" />
<circle
cx="10"
cy="10"
r="8"
fill="none"
stroke={color}
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={isContextStale ? `${RING_CIRCUMFERENCE * 0.35} ${RING_CIRCUMFERENCE}` : `${RING_CIRCUMFERENCE}`}
strokeDashoffset={isCompacting ? RING_CIRCUMFERENCE * 0.25 : offset}
style={{
transition: 'stroke-dashoffset 0.4s ease, stroke 0.4s ease',
...(isCompacting ? { animation: 'spin 1.5s linear infinite', transformOrigin: 'center' } : {}),
}}
/>
</svg>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<span className="text-xs">{tooltipText}</span>
</TooltipContent>
</Tooltip>
);
}
18 changes: 18 additions & 0 deletions src/components/chat/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { nanoid } from 'nanoid';
import { ImageGenToggle } from './ImageGenToggle';
import { useImageGen } from '@/hooks/useImageGen';
import { PENDING_KEY, setRefImages, deleteRefImages } from '@/lib/image-ref-store';
import { ContextUsageRing } from './ContextUsageRing';

const IMAGE_AGENT_SYSTEM_PROMPT = `你是一个图像生成助手。当用户请求生成图片时,分析用户意图并以结构化格式输出。

Expand Down Expand Up @@ -97,6 +98,10 @@ interface MessageInputProps {
workingDirectory?: string;
mode?: string;
onModeChange?: (mode: string) => void;
contextTokens?: number;
contextStale?: boolean;
maxContext?: number;
isCompacting?: boolean;
}

interface PopoverItem {
Expand Down Expand Up @@ -373,6 +378,10 @@ export function MessageInput({
workingDirectory,
mode = 'code',
onModeChange,
contextTokens = 0,
contextStale = false,
maxContext = 200000,
isCompacting = false,
}: MessageInputProps) {
const { t } = useTranslation();
const imageGen = useImageGen();
Expand Down Expand Up @@ -1178,6 +1187,14 @@ export function MessageInput({
)}
</div>

{/* Context usage ring */}
<ContextUsageRing
contextTokens={contextTokens}
contextStale={contextStale}
maxContext={maxContext}
isCompacting={isCompacting}
t={t}
/>
{/* Image Agent toggle */}
<ImageGenToggle />
</PromptInputTools>
Expand All @@ -1197,3 +1214,4 @@ export function MessageInput({
</div>
);
}

46 changes: 46 additions & 0 deletions src/components/chat/context-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { TranslationKey } from '@/i18n';

export const DEFAULT_MAX_CONTEXT = 200000;
export const RING_RADIUS = 8;
export const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;

export const CONTEXT_USAGE_COLORS = {
compacting: '#a855f7',
stale: '#64748b',
danger: '#ef4444',
warning: '#eab308',
safe: '#22c55e',
} as const;

export function formatTokenCount(value: number): string {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${Math.round(value / 1000)}k`;
return `${value}`;
}

export function getContextUsageRatio(contextTokens: number, maxContext: number, isContextStale: boolean): number {
if (isContextStale) return 0;
if (maxContext <= 0) return 0;
return Math.min(Math.max(contextTokens / maxContext, 0), 1);
}

export function getContextUsageColor(ratio: number, isCompacting: boolean, isContextStale: boolean): string {
if (isCompacting) return CONTEXT_USAGE_COLORS.compacting;
if (isContextStale) return CONTEXT_USAGE_COLORS.stale;
if (ratio > 0.7) return CONTEXT_USAGE_COLORS.danger;
if (ratio > 0.5) return CONTEXT_USAGE_COLORS.warning;
return CONTEXT_USAGE_COLORS.safe;
}

export function getContextUsageTooltip(
contextTokens: number,
maxContext: number,
isCompacting: boolean,
isContextStale: boolean,
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
if (isCompacting) return t('messageInput.compacting');
if (isContextStale) return t('messageInput.contextRefreshPending');
return `${formatTokenCount(contextTokens)} / ${formatTokenCount(maxContext)}`;
}

18 changes: 17 additions & 1 deletion src/hooks/useSSEStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export interface SSECallbacks {
onTaskUpdate: (sessionId: string) => void;
onKeepAlive: () => void;
onError: (accumulated: string) => void;
onCompactBoundary?: () => void;
onCompacting?: () => void;
onContextTokens?: (tokens: number) => void;
}

/**
Expand Down Expand Up @@ -88,8 +91,13 @@ function handleSSEEvent(
case 'status': {
try {
const statusData = JSON.parse(event.data);
if (statusData.session_id) {
if (typeof statusData.context_tokens === 'number') {
callbacks.onContextTokens?.(statusData.context_tokens);
return accumulated;
} else if (statusData.session_id) {
callbacks.onStatus(`Connected (${statusData.model || 'claude'})`);
} else if (statusData.compacting) {
callbacks.onCompacting?.();
} else if (statusData.notification) {
callbacks.onStatus(statusData.message || statusData.title || undefined);
} else {
Expand All @@ -101,6 +109,11 @@ function handleSSEEvent(
return accumulated;
}

case 'compact_boundary': {
callbacks.onCompactBoundary?.();
return accumulated;
}

case 'result': {
try {
const resultData = JSON.parse(event.data);
Expand Down Expand Up @@ -240,6 +253,9 @@ export function useSSEStream() {
onTaskUpdate: (s) => callbacksRef.current?.onTaskUpdate(s),
onKeepAlive: () => callbacksRef.current?.onKeepAlive(),
onError: (a) => callbacksRef.current?.onError(a),
onCompactBoundary: () => callbacksRef.current?.onCompactBoundary?.(),
onCompacting: () => callbacksRef.current?.onCompacting?.(),
onContextTokens: (t) => callbacksRef.current?.onContextTokens?.(t),
};

return consumeSSEStream(reader, proxied);
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const en = {
'messageInput.modeCode': 'Code',
'messageInput.modePlan': 'Plan',
'messageInput.aiSuggested': 'AI Suggested',
'messageInput.compacting': 'Compacting...',
'messageInput.contextRefreshPending': 'Context compacted. Token gauge updates on the next turn.',

// ── Streaming message ───────────────────────────────────────
'streaming.thinking': 'Thinking...',
Expand Down
Loading