feat(web): improve chat attachments, unread marks, and session UX

Support richer attachment payloads in input area, local read-id cache for
unread badges, and expand private-shared and chronology test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:21 +00:00
committed by weichen
co-authored by Cursor
parent 6f4e8c0933
commit cda534a75d
7 changed files with 440 additions and 71 deletions
+2 -1
View File
@@ -59,8 +59,9 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
: new Date().toTimeString().substring(0, 5);
const meta = r.metadata || {};
const msgId = meta.client_id || (r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`);
return {
id: r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
id: msgId,
serverId: r.id,
sessionId: r.session_id,
role: r.role,
+169 -45
View File
@@ -3,10 +3,19 @@ import { Send, Paperclip, Square, X, Loader2, Sparkles, Lock } from 'lucide-reac
import { QuotedMessage } from '../../types/workbench';
import { QuoteChip } from './QuoteChip';
export interface InputAttachmentItem {
name: string;
size: string;
type: string;
docId?: string;
fileRaw?: File | Blob;
fileUrl?: string;
}
interface InputAreaProps {
onSendMessage: (
text: string,
attachments?: { name: string; size: string; type: string }[],
attachments?: InputAttachmentItem[],
quotes?: QuotedMessage[]
) => void;
isThinking: boolean;
@@ -18,6 +27,8 @@ interface InputAreaProps {
disabledMessage?: string;
}
const MAX_ATTACHMENTS = 5;
export const InputArea: React.FC<InputAreaProps> = ({
onSendMessage,
isThinking,
@@ -29,7 +40,8 @@ export const InputArea: React.FC<InputAreaProps> = ({
disabledMessage,
}) => {
const [text, setText] = useState('');
const [attachments, setAttachments] = useState<{ name: string; size: string; type: string }[]>([]);
const [attachments, setAttachments] = useState<InputAttachmentItem[]>([]);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -56,6 +68,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
onSendMessage(text.trim(), attachments, quotes);
setText('');
setAttachments([]);
setUploadWarning(null);
if (textareaRef.current) {
textareaRef.current.style.height = '42px';
}
@@ -69,23 +82,65 @@ export const InputArea: React.FC<InputAreaProps> = ({
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
const sizeStr = (file.size / 1024).toFixed(0) + ' KB';
setAttachments((prev) => [
...prev,
{
name: file.name,
size: sizeStr,
type: file.type || 'document',
},
]);
if (!e.target.files || e.target.files.length === 0) return;
const files = Array.from(e.target.files);
const currentCount = attachments.length;
const availableSlots = MAX_ATTACHMENTS - currentCount;
if (availableSlots <= 0) {
setUploadWarning(`单轮对话最多支持上传 ${MAX_ATTACHMENTS} 个文档`);
setTimeout(() => setUploadWarning(null), 3000);
e.target.value = '';
return;
}
let filesToAdd = files;
if (files.length > availableSlots) {
filesToAdd = files.slice(0, availableSlots);
setUploadWarning(
`单轮对话最多支持上传 ${MAX_ATTACHMENTS} 个文档,已为您选取前 ${availableSlots} 个`
);
setTimeout(() => setUploadWarning(null), 3500);
} else {
setUploadWarning(null);
}
const newAtts: InputAttachmentItem[] = filesToAdd.map((file) => {
let fileUrl = '';
try {
if (typeof window !== 'undefined' && window.URL && window.URL.createObjectURL) {
fileUrl = window.URL.createObjectURL(file);
}
} catch (err) {
console.warn('Failed to create object URL for file:', file.name, err);
}
return {
name: file.name,
size: (file.size / 1024).toFixed(0) + ' KB',
type: file.type || 'document',
fileRaw: file,
fileUrl,
};
});
setAttachments((prev) => [...prev, ...newAtts]);
e.target.value = '';
};
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
setAttachments((prev) => {
const target = prev[index];
if (target?.fileUrl && target.fileUrl.startsWith('blob:') && typeof window !== 'undefined' && window.URL?.revokeObjectURL) {
try {
window.URL.revokeObjectURL(target.fileUrl);
} catch (e) {
// ignore
}
}
return prev.filter((_, i) => i !== index);
});
if (uploadWarning) setUploadWarning(null);
};
if (disabled) {
@@ -137,15 +192,50 @@ export const InputArea: React.FC<InputAreaProps> = ({
position: 'relative',
}}
>
{/* 隐藏的原生文件上传 */}
{/* 隐藏的原生文件上传(支持单次多选,单轮累计最多 5 个) */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.md,.txt,.xlsx"
/>
{/* 超出限制或操作提示条 */}
{uploadWarning && (
<div
style={{
padding: '6px 10px',
borderRadius: 'var(--r-sm, 6px)',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.3)',
color: '#d97706',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
animation: 'fadeIn 0.2s ease',
}}
>
<span>⚠️ {uploadWarning}</span>
<button
onClick={() => setUploadWarning(null)}
style={{
border: 'none',
background: 'transparent',
color: '#d97706',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '2px',
}}
>
<X size={12} />
</button>
</div>
)}
{/* 已挂载引用卡片 */}
{quotes.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
@@ -157,31 +247,51 @@ export const InputArea: React.FC<InputAreaProps> = ({
{/* 已挂载文件芯片 */}
{attachments.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{attachments.map((att, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '3px 8px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--brand-100)',
fontSize: '11px',
color: 'var(--brand-600)',
}}
>
<span>📎 {att.name}</span>
<button
onClick={() => removeAttachment(i)}
style={{ display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-muted, #64748b)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span>待上传文档 ({attachments.length}/{MAX_ATTACHMENTS})</span>
{attachments.length < MAX_ATTACHMENTS && (
<span style={{ fontSize: '10px', color: 'var(--text-faint, #94a3b8)' }}>
还可添加 {MAX_ATTACHMENTS - attachments.length} 个
</span>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{attachments.map((att, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '3px 8px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--brand-100)',
fontSize: '11px',
color: 'var(--brand-600)',
}}
>
<X size={12} />
</button>
</div>
))}
<span>📎 {att.name} ({att.size})</span>
<button
onClick={() => removeAttachment(i)}
title="移除此文档"
style={{ display: 'flex', alignItems: 'center', color: 'var(--text-faint)', cursor: 'pointer' }}
>
<X size={12} />
</button>
</div>
))}
</div>
</div>
)}
@@ -216,8 +326,19 @@ export const InputArea: React.FC<InputAreaProps> = ({
>
{/* 附件按钮 */}
<button
onClick={() => fileInputRef.current?.click()}
title="上传项目需求材料或参考文档"
onClick={() => {
if (attachments.length >= MAX_ATTACHMENTS) {
setUploadWarning(`单轮对话最多支持上传 ${MAX_ATTACHMENTS} 个文档(当前已达上限)`);
setTimeout(() => setUploadWarning(null), 3000);
return;
}
fileInputRef.current?.click();
}}
title={
attachments.length >= MAX_ATTACHMENTS
? `已达上限 (最多 ${MAX_ATTACHMENTS} 个文档)`
: `上传项目需求材料或参考文档 (最多 ${MAX_ATTACHMENTS} 个,支持多选)`
}
style={{
width: '32px',
height: '32px',
@@ -225,17 +346,20 @@ export const InputArea: React.FC<InputAreaProps> = ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
color: attachments.length >= MAX_ATTACHMENTS ? 'var(--text-faint)' : 'var(--text-muted)',
flexShrink: 0,
marginBottom: '4px',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--brand-500)';
e.currentTarget.style.background = 'var(--bg-hover)';
if (attachments.length < MAX_ATTACHMENTS) {
e.currentTarget.style.color = 'var(--brand-500)';
e.currentTarget.style.background = 'var(--bg-hover)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.color =
attachments.length >= MAX_ATTACHMENTS ? 'var(--text-faint)' : 'var(--text-muted)';
e.currentTarget.style.background = 'transparent';
}}
>
+37 -6
View File
@@ -501,14 +501,45 @@ export function useChatWindow({
todayBucket.loadedCount++;
}
// 严谨保序:按照 liveMessages 中的自然发生时序排列,保证新追加的对话永远沉淀在最底部
const getMessageSortKey = (m: ChatMessage): number => {
if (m.id && liveIndexMap.has(m.id)) return liveIndexMap.get(m.id)!;
if (m.serverId && liveIndexMap.has(`msg-${m.serverId}`)) return liveIndexMap.get(`msg-${m.serverId}`)!;
if (m.serverId) return -1000000 + m.serverId;
// 严谨保序:按照真实时间与发生时序排列,保证新追加的对话永远沉淀在最底部
const getMessageSortTime = (m: ChatMessage): number => {
if (m.createdAt) {
const t = new Date(m.createdAt).getTime();
if (!isNaN(t)) return t;
}
if (m.timestamp && m.timestamp.includes(':')) {
const [hh, mm] = m.timestamp.split(':').map(Number);
if (!isNaN(hh) && !isNaN(mm)) {
const d = new Date();
d.setHours(hh, mm, 0, 0);
return d.getTime();
}
}
if (m.id && (m.id.startsWith('msg-') || m.id.startsWith('ast-'))) {
const numPart = m.id.replace(/^[a-z]+-/, '');
const parsed = parseInt(numPart, 10);
if (!isNaN(parsed) && parsed > 1000000000000) {
return parsed;
}
}
return 0;
};
todayBucket.messages.sort((a, b) => getMessageSortKey(a) - getMessageSortKey(b));
todayBucket.messages.sort((a, b) => {
const tA = getMessageSortTime(a);
const tB = getMessageSortTime(b);
if (tA !== 0 && tB !== 0 && tA !== tB) {
return tA - tB;
}
const idxA = liveIndexMap.get(a.id) ?? (a.serverId ? liveIndexMap.get(`msg-${a.serverId}`) : undefined);
const idxB = liveIndexMap.get(b.id) ?? (b.serverId ? liveIndexMap.get(`msg-${b.serverId}`) : undefined);
if (idxA !== undefined && idxB !== undefined) {
return idxA - idxB;
}
if (idxA !== undefined) return 1;
if (idxB !== undefined) return -1;
return (a.serverId || 0) - (b.serverId || 0);
});
return result;
}, [dayStack, liveMessages])();
+60 -19
View File
@@ -44,6 +44,22 @@ export interface UseUnreadMarksReturn {
refresh: () => void;
}
function getLocalReadIds(sessionId: string): Set<number> {
try {
const raw = localStorage.getItem(`smdf_read_ids_${sessionId}`);
if (raw) return new Set(JSON.parse(raw));
} catch {}
return new Set();
}
function saveLocalReadId(sessionId: string, msgId: number) {
try {
const ids = getLocalReadIds(sessionId);
ids.add(msgId);
localStorage.setItem(`smdf_read_ids_${sessionId}`, JSON.stringify(Array.from(ids).slice(-200)));
} catch {}
}
export function useUnreadMarks(
sessionId: string | null,
userId?: number,
@@ -78,34 +94,52 @@ export function useUnreadMarks(
if (!res.ok) return;
const data = await res.json();
const localReadSet = getLocalReadIds(sessionId);
// 兼容老格式 (Array) 与新格式 (UnreadSummaryResponse)
if (Array.isArray(data)) {
const items: UnreadMark[] = data.map((item: UnreadDayApiItem) => ({
sessionId: sessionId,
date: item.date,
count: item.count,
lastMessageId: item.last_message_id,
senders: item.senders,
}));
const rawItems: UnreadDayApiItem[] = data;
const items: UnreadMark[] = rawItems
.map((item) => {
const isReadLocally = localReadSet.has(item.last_message_id);
const count = isReadLocally ? Math.max(0, item.count - 1) : item.count;
return {
sessionId: sessionId,
date: item.date,
count,
lastMessageId: item.last_message_id,
senders: item.senders,
};
})
.filter((item) => item.count > 0);
setUnreadMarks(items);
const total = items.reduce((sum, item) => sum + item.count, 0);
setUnreadTotal(total);
setNextMessageId(items.length > 0 ? items[0].lastMessageId : null);
setUnreadMessageIds(items.map((i) => i.lastMessageId));
const validIds = items.map((i) => i.lastMessageId).filter((id) => !localReadSet.has(id));
setNextMessageId(validIds.length > 0 ? validIds[0] : null);
setUnreadMessageIds(validIds);
} else {
const summary = data as UnreadSummaryResponse;
const items: UnreadMark[] = (summary.items || []).map((item) => ({
sessionId: sessionId,
date: item.date,
count: item.count,
lastMessageId: item.last_message_id,
senders: item.senders,
}));
const unreadIds = (summary.unread_message_ids || []).filter((id) => !localReadSet.has(id));
const items: UnreadMark[] = (summary.items || [])
.map((item) => {
const isReadLocally = localReadSet.has(item.last_message_id);
const count = isReadLocally ? Math.max(0, item.count - 1) : item.count;
return {
sessionId: sessionId,
date: item.date,
count,
lastMessageId: item.last_message_id,
senders: item.senders,
};
})
.filter((item) => item.count > 0);
setUnreadMarks(items);
setUnreadTotal(summary.total || 0);
setNextMessageId(summary.next_message_id > 0 ? summary.next_message_id : null);
setUnreadMessageIds(summary.unread_message_ids || []);
setUnreadTotal(unreadIds.length);
setNextMessageId(unreadIds.length > 0 ? unreadIds[0] : null);
setUnreadMessageIds(unreadIds);
}
} catch {
// 网络错误静默处理,不中断协同
@@ -137,6 +171,9 @@ export function useUnreadMarks(
}
if (!targetId) return null;
// 0. 本地持久化记录已读
saveLocalReadId(sessionId, targetId);
// 1. 乐观更新未读集合
const remainingIds = unreadMessageIds.filter((id) => id !== targetId);
setUnreadMessageIds(remainingIds);
@@ -190,6 +227,10 @@ export function useUnreadMarks(
async (messageIds: number[]) => {
if (!sessionId || messageIds.length === 0) return;
for (const id of messageIds) {
saveLocalReadId(sessionId, id);
}
const idsToRead = new Set(messageIds);
const remainingIds = unreadMessageIds.filter((id) => !idsToRead.has(id));
setUnreadMessageIds(remainingIds);
+112
View File
@@ -148,4 +148,116 @@ describe('用户上传文档私密归档与共享流转测试', () => {
assert.equal(newDeliverable.category, 'private');
assert.equal(newDeliverable.space, 'my_private');
});
it('单轮对话支持一次上传多个文档(上限5个),批量生成私密交付物并遵循数量约束', () => {
const MAX_ATTACHMENTS = 5;
// 模拟用户一次性多选 6 个文件
const incomingFiles = [
{ name: '1-临床需求规格书.docx', size: '120 KB', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
{ name: '2-硬件设计草案.pdf', size: '350 KB', type: 'application/pdf' },
{ name: '3-竞品比对表.xlsx', size: '80 KB', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
{ name: '4-法规风险调研.md', size: '15 KB', type: 'text/markdown' },
{ name: '5-型检预估预算.xlsx', size: '45 KB', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
{ name: '6-超出限制的文件.pdf', size: '200 KB', type: 'application/pdf' },
];
// 模拟 InputArea handleFileChange 截断逻辑
let currentAttachments: { name: string; size: string; type: string }[] = [];
const availableSlots = MAX_ATTACHMENTS - currentAttachments.length;
assert.equal(availableSlots, 5);
let filesToAdd = incomingFiles;
let warningTriggered = false;
if (incomingFiles.length > availableSlots) {
filesToAdd = incomingFiles.slice(0, availableSlots);
warningTriggered = true;
}
assert.equal(warningTriggered, true);
assert.equal(filesToAdd.length, 5);
currentAttachments = [...currentAttachments, ...filesToAdd];
assert.equal(currentAttachments.length, 5);
// 再次尝试添加:此时应无剩余槽位并拒绝
const nextAvailableSlots = MAX_ATTACHMENTS - currentAttachments.length;
assert.equal(nextAvailableSlots, 0);
// 模拟 App.tsx handleSendMessage 批量注册
const currentProjectId = 6001;
const currentUserId = 1;
const currentUserName = '张工';
const now = Date.now();
const newDeliverablesList: DeliverableItem[] = [];
const newDocCacheEntries: Record<string, any> = {};
const processedAttachments = currentAttachments.map((att, idx) => {
const fileDocId = `doc-upload-${now}-${idx}`;
const baseTitle = att.name.replace(/\.[^/.]+$/, '');
const newDeliverable: DeliverableItem = {
docId: fileDocId,
projectId: currentProjectId,
taskKey: 'user_requirements',
title: baseTitle,
stage: 'V0.5',
category: 'private',
creatorId: currentUserId,
creatorName: currentUserName,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUserName,
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-11',
summary: `用户上传的私密需求文档《${att.name}》`,
space: 'my_private',
governanceMode: 'freeform',
};
const initialMd = `# ${baseTitle}\n\n> 上传人:${currentUserName} | 大小:${att.size}`;
const newAST = markdownToBlockAST(initialMd, {
docId: fileDocId,
title: baseTitle,
taskKey: 'user_requirements',
version: 'V1.0',
category: 'private',
creatorId: currentUserId,
creatorName: currentUserName,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUserName,
reviewer: '李主管',
approver: '王总监',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
newDeliverablesList.push(newDeliverable);
newDocCacheEntries[fileDocId] = newAST;
return {
...att,
docId: fileDocId,
};
});
assert.equal(processedAttachments.length, 5);
assert.equal(newDeliverablesList.length, 5);
assert.equal(Object.keys(newDocCacheEntries).length, 5);
// 验证所有 5 份交付物均具有独立 docId 且为 private / my_private
const docIds = new Set(newDeliverablesList.map((d) => d.docId));
assert.equal(docIds.size, 5);
newDeliverablesList.forEach((d) => {
assert.equal(d.category, 'private');
assert.equal(d.space, 'my_private');
assert.equal(d.creatorName, '张工');
});
// 验证默认激活打开第 1 个文档
assert.equal(processedAttachments[0].docId, newDeliverablesList[0].docId);
});
});
@@ -98,4 +98,47 @@ describe('工作流切换与消息展示时序测试套件', () => {
assert.equal(flat[4].id, 'msg-live-1');
assert.equal(flat[5].id, 'ast-live-2');
});
test('2. 页面刷新后,即使本地 liveMessages 未包含新消息,按时间戳与自增 ID 排序也绝对不倒挂在历史消息前', () => {
// 模拟服务端返回完整按时间排布的消息
const historicalMessages: ChatMessage[] = [
{ id: 'm-jigu-1', sessionId: 'sess-jigu-u1', role: 'system', content: '助手就位', timestamp: '09:00', createdAt: '2026-09-11T09:00:00+08:00', serverId: 25217 },
{ id: 'm-jigu-2', sessionId: 'sess-jigu-u1', role: 'user', content: '用户需求', timestamp: '09:14', createdAt: '2026-09-11T09:14:00+08:00', serverId: 25218 },
];
const serverRecentMessages: ChatMessage[] = [
{ id: 'msg-25227', sessionId: 'sess-jigu-u1', role: 'user', content: '分析一下测试方案中...', timestamp: '10:06', createdAt: '2026-09-11T10:06:46+08:00', serverId: 25227 },
{ id: 'msg-25228', sessionId: 'sess-jigu-u1', role: 'assistant', content: '针对测试方案...', timestamp: '10:07', createdAt: '2026-09-11T10:07:26+08:00', serverId: 25228 },
];
const allInBucket = [...serverRecentMessages, ...historicalMessages]; // 故意打乱测试排序算法鲁棒性
const getMessageSortTime = (m: ChatMessage): number => {
if (m.createdAt) {
const t = new Date(m.createdAt).getTime();
if (!isNaN(t)) return t;
}
if (m.timestamp && m.timestamp.includes(':')) {
const [hh, mm] = m.timestamp.split(':').map(Number);
if (!isNaN(hh) && !isNaN(mm)) {
const d = new Date();
d.setHours(hh, mm, 0, 0);
return d.getTime();
}
}
return 0;
};
allInBucket.sort((a, b) => {
const tA = getMessageSortTime(a);
const tB = getMessageSortTime(b);
if (tA !== 0 && tB !== 0 && tA !== tB) return tA - tB;
return (a.serverId || 0) - (b.serverId || 0);
});
assert.equal(allInBucket[0].id, 'm-jigu-1');
assert.equal(allInBucket[1].id, 'm-jigu-2');
assert.equal(allInBucket[2].id, 'msg-25227');
assert.equal(allInBucket[3].id, 'msg-25228');
});
});
+17
View File
@@ -144,6 +144,8 @@ export interface ChatMessage {
size: string;
type: string;
docId?: string;
fileRaw?: File | Blob;
fileUrl?: string;
}[];
intentTag?: string;
branchTag?: string;
@@ -228,6 +230,19 @@ export interface ChatCategoryStat {
export type DeliverableCategory = 'workflow' | 'shared' | 'private';
export type DeliverableSourceType = 'workflow' | 'chat_derived' | 'uploaded_file';
export interface UploadedFileMetadata {
originalName: string;
size: string;
sizeBytes?: number;
mimeType: string;
extension: string;
fileRaw?: File | Blob;
fileUrl?: string;
uploadedAt: string;
}
export interface DeliverableItem {
docId: string;
projectId: number;
@@ -251,6 +266,8 @@ export interface DeliverableItem {
visibilityRule?: DocVisibilityRule;
governanceMode?: 'controlled' | 'freeform';
space?: 'my_private' | 'project_shared' | 'trash';
sourceType?: DeliverableSourceType;
fileMeta?: UploadedFileMetadata;
}
export interface WorkbenchState {