Skip to content
Merged
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
18 changes: 6 additions & 12 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2341,21 +2341,15 @@ jobs:
- name: Self-test shared component trio ratchet (negative)
run: python scripts/verify/tests/verify-shared-trio-ratchet.Tests.py

# #1612: i18n callsites ratchet — shared UI must not add new CJK
# string literals (every user-visible string routes through the i18n
# dictionary). The gate enforces a non-increasing baseline; the script
# exits non-zero when the current CJK-literal count exceeds baseline.
# Wired advisory (continue-on-error) only because two frontend files
# (ui/CodeBlock.tsx, workbench/workbenchTestMocks.ts) regressed the
# baseline in the current working tree and this lane cannot touch app/.
# Once those violations are backfilled below baseline, flip this to
# hard-blocking by removing continue-on-error. The gate itself is not
# weakened — the script runs on every PR and posts a failure annotation
# either way.
# #1612: per-file i18n debt ratchet. A file may keep/reduce existing CJK
# string-literal debt but cannot add more; importing useTranslation is not
# an exemption, and debt repaid in one file cannot mask growth in another.
- name: Verify i18n callsites ratchet (#1612)
continue-on-error: true
run: python ./scripts/verify/verify-i18n-callsites.py

- name: Self-test i18n callsites ratchet (negative)
run: python scripts/verify/tests/verify-i18n-callsites.Tests.py

# ── Frontend coverage baseline gate (path-filtered) ──
# Split out of the unconditional validate lane (audit): a Go-only PR no
# longer pays for pnpm install + vitest --coverage. v8 coverage is
Expand Down
18 changes: 14 additions & 4 deletions app/shared/src/chatview/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,6 @@ export const chatviewResources = {
'toast.cardCopied': '已复制卡片内容',
'toast.forwardQueued': '已加入转发队列',
'toast.pinUpdated': '已更新置顶',
'toast.linkCopied': '已复制消息链接',
'toast.deleteQueued': '已标记删除',
'toast.editStarted': '开始编辑消息',
'toast.unpinned': '已取消置顶',
Expand Down Expand Up @@ -482,7 +481,6 @@ export const chatviewResources = {
// 菜单切换见 workbenchTranscriptChromeActionMappers.ts)。
'context.unpin': '取消置顶',
'context.recall': '撤回消息',
'context.copyLink': '复制消息链接',
'context.regenerate': '重新生成',
'context.delete': '删除',
'context.edit': '编辑',
Expand Down Expand Up @@ -877,6 +875,13 @@ export const chatviewResources = {
'preview.retry': '重试',
'preview.blankSlide': '空白幻灯片',
'preview.emptySheet': '空工作表',
'preview.error.fetch': '文件加载失败(HTTP {{status}})',
'preview.error.docxParse': '文档解析失败',
'preview.error.tableParse': '表格解析失败',
'preview.error.slideshowParse': '演示文稿解析失败',
'preview.error.emptyWorkbook': '工作簿中没有可显示的工作表',
'preview.error.emptySlideshow': '演示文稿中没有可显示的幻灯片',
'preview.error.tooLarge': '文件过大({{actualMb}} MB,上限 {{limitMb}} MB),出于安全限制不预览',
'filePreview.imageViaFileUrl': '图片内容将通过文件 URL 加载',
'inspector.runtimeEvidence': '运行证据',
'inspector.status.pending': '等待',
Expand Down Expand Up @@ -1193,7 +1198,6 @@ export const chatviewResources = {
'toast.cardCopied': 'Card content copied',
'toast.forwardQueued': 'Added to forward queue',
'toast.pinUpdated': 'Pin updated',
'toast.linkCopied': 'Message link copied',
'toast.deleteQueued': 'Marked for deletion',
'toast.editStarted': 'Editing message',
'toast.unpinned': 'Message unpinned',
Expand Down Expand Up @@ -1381,7 +1385,6 @@ export const chatviewResources = {
// pinMap store; menu toggle in workbenchTranscriptChromeActionMappers.ts).
'context.unpin': 'Unpin message',
'context.recall': 'Recall message',
'context.copyLink': 'Copy message link',
'context.regenerate': 'Regenerate',
'context.delete': 'Delete',
'context.edit': 'Edit',
Expand Down Expand Up @@ -1773,6 +1776,13 @@ export const chatviewResources = {
'preview.retry': 'Retry',
'preview.blankSlide': 'Blank slide',
'preview.emptySheet': 'Empty sheet',
'preview.error.fetch': 'File load failed (HTTP {{status}})',
'preview.error.docxParse': 'Failed to parse document',
'preview.error.tableParse': 'Failed to parse spreadsheet',
'preview.error.slideshowParse': 'Failed to parse presentation',
'preview.error.emptyWorkbook': 'No displayable sheets were found in the workbook',
'preview.error.emptySlideshow': 'No displayable slides were found in the presentation',
'preview.error.tooLarge': 'File is too large ({{actualMb}} MB; limit {{limitMb}} MB) and cannot be previewed safely',
'filePreview.imageViaFileUrl': 'Image content will load via file URL',
'inspector.runtimeEvidence': 'Runtime evidence',
'inspector.status.pending': 'Pending',
Expand Down
2 changes: 1 addition & 1 deletion app/shared/src/chatview/transcript-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface TranscriptAgentItem {
export type TranscriptItem = TranscriptUserItem | TranscriptAgentItem

/** Generic action callback from Transcript to consumer.
* Actions: 'copy', 'regenerate', 'pin', 'reply', 'quote', 'link', 'delete'.
* Actions: 'copy', 'regenerate', 'pin', 'reply', 'quote', 'delete'.
* Metadata carries action-specific payload (e.g. selected text for copy). */
export type BlockActionCallback = (
action: string,
Expand Down
28 changes: 28 additions & 0 deletions app/shared/src/i18n/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ export const sharedWorkbenchResources = {
unavailable: '搜索尚未接入',
unavailableHint: '该搜索框还没有接入数据源,暂时不可用。',
},
hubMapping: {
externalOrg: '外部联系人',
online: '在线',
offline: '离线',
untitledDocument: '未命名文档',
myDocuments: '我的文档库',
todayAt: '今天 {{time}}',
yesterdayAt: '昨天 {{time}}',
monthDayAt: '{{month}}月{{day}}日 {{time}}',
privateSession: '私聊',
groupSession: '群会话',
groupSessionWithId: '群会话 {{id}}',
memberCount: '{{count}} 人',
},
inspector: {
overview: '概览',
browser: '浏览器',
Expand Down Expand Up @@ -817,6 +831,20 @@ export const sharedWorkbenchResources = {
unavailable: 'Search not wired up yet',
unavailableHint: 'This search box is not connected to a data source yet, so it stays disabled.',
},
hubMapping: {
externalOrg: 'External contact',
online: 'Online',
offline: 'Offline',
untitledDocument: 'Untitled document',
myDocuments: 'My documents',
todayAt: 'Today {{time}}',
yesterdayAt: 'Yesterday {{time}}',
monthDayAt: '{{month}}/{{day}} {{time}}',
privateSession: 'Direct message',
groupSession: 'Group conversation',
groupSessionWithId: 'Group conversation {{id}}',
memberCount: '{{count}} members',
},
inspector: {
overview: 'Overview',
browser: 'Browser',
Expand Down
40 changes: 10 additions & 30 deletions app/shared/src/stores/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,9 @@ export const hubQueryKeys = {
},

// Threads (sessions)
// Canonical key shape (ADR-029 / #2261):
// - `root` is an INVALIDATION PREFIX ONLY. It must never be the queryKey of
// a live query: web used it as its session-list key while desktop used the
// same prefix for transcripts, so "refresh the session list" and "refresh
// one transcript" meant opposite things in the two shells.
// - the collection query uses `list`; a per-thread sub-resource uses
// `messages`/`pins` and always has a factory (no literal keys at call
// sites);
// - a factory with no consumer must not exist. `detail` was deleted for
// exactly that reason: 0 useQuery consumers against 9 production
// invalidation sites, and `['hub','threads','detail',id]` is not a prefix
// of `['hub',id,'messages']`, so all 9 matched no cache entry at all —
// the same failure mode as #2252, one key over. Do not reintroduce it
// without a real thread-detail query to consume it.
// Canonical key shape (ADR-029): `root` is invalidation-only; live
// collection queries use `list`, and per-thread resources use factories.
// Do not add a factory without a real query producer that consumes it.
threads: {
root: ['hub', 'threads'] as const,
list: ['hub', 'threads', 'list'] as const,
Expand Down Expand Up @@ -147,17 +136,9 @@ export const hubQueryKeys = {
// it on logout yields a different key, so signed-out UI cannot keep showing
// signed-in data (a bare `enabled: false` would leave the old entry cached).
//
// Canonical rules (ADR-029 / #2261 residual, landed here):
// - this factory is the ONLY producer of `web-v4` literals. Call sites must
// not write the array by hand: a hand-written invalidation key is exactly
// how `['web-v4','contacts']` / `['hub','contacts','list']` / `['agent-teams']`
// came to invalidate caches that no query ever wrote (#2252 and #2310 are
// the same failure mode on other platforms).
// - `*.root` is an invalidation prefix only, never a live query's `queryKey`.
// - a member with no query producer must not exist. Web has no notifications
// query, no team-runs query and no `web-v4` execution-targets query, so
// those keys are deliberately absent — the invalidations that used to name
// them were dead and have been removed at the call site.
// Canonical rules (ADR-029/ADR-035): this factory is the only producer of
// `web-v4` literals; `*.root` is invalidation-only; and every member must have a
// live query producer. Call sites reference factories, never literal arrays.
/**
* A cache pointer that is legitimately *absent* while its query is disabled
* (no active session / no active task). It is threaded through verbatim on
Expand Down Expand Up @@ -229,10 +210,9 @@ export const webQueryKeys = {
// invalidate, the exact per-session key, and the reverse matcher that recovers
// the session id from a key found in the cache.
//
// Adding another transcript cache? Export a family next to the key factory
// that produces it and hand it to the resync helper. Do NOT teach the helper
// about literal key shapes: that is how #2101 G1/G4-② stayed a silent no-op on
// both platforms while every test using the literal shape passed.
// Adding another transcript cache? Export a family next to its key factory
// and hand that family to the resync helper; do not teach consumers literal
// key shapes.
export interface HubMessagesKeyFamily {
/** Broad prefix covering every session's transcript — invalidation target. */
readonly root: readonly unknown[];
Expand Down Expand Up @@ -315,7 +295,7 @@ export function isQueryKeyPrefix(

/** Get a broad invalidation key (root prefix) from a specific query key. */
export function rootPrefix(key: readonly unknown[]): readonly unknown[] {
// Return the first 2-3 segments as a broad invalidation target
// Longer keys collapse to their first two segments.
if (key.length <= 2) return key;
return key.slice(0, 2);
}
8 changes: 4 additions & 4 deletions app/shared/src/ui/DocxPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ export const DocxPreview: React.FC<DocxPreviewProps> = ({
} else {
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
setError(t('preview.error.fetch', { status: response.status }));
return;
}
arrayBuffer = await response.arrayBuffer();
}
Expand All @@ -80,9 +81,8 @@ export const DocxPreview: React.FC<DocxPreviewProps> = ({
const { default: DOMPurify } = await import('dompurify');
const sanitized: string = DOMPurify.sanitize(result.value, purifyConfig.current);
setHtml(sanitized);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error parsing DOCX file';
setError(message);
} catch {
setError(t('preview.error.docxParse'));
} finally {
setLoading(false);
}
Expand Down
6 changes: 3 additions & 3 deletions app/shared/src/ui/SlideshowPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,9 @@ describe('SlideshowPreview', () => {

it('shows error state when parsing fails', async () => {
loadAsyncMock.mockRejectedValueOnce(new Error('parse failed'));
const { findByText, getByRole } = renderSlideshow();
// The component surfaces err.message into the error block.
expect(await findByText('parse failed')).toBeInTheDocument();
const { findByText, getByRole, queryByText } = renderSlideshow();
expect(await findByText('演示文稿解析失败')).toBeInTheDocument();
expect(queryByText('parse failed')).not.toBeInTheDocument();
// Retry button is rendered with its visible text label.
expect(getByRole('button', { name: '重试' })).toBeInTheDocument();
});
Expand Down
11 changes: 6 additions & 5 deletions app/shared/src/ui/SlideshowPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,19 +173,20 @@ export const SlideshowPreview: React.FC<SlideshowPreviewProps> = ({
} else {
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
setError(t('preview.error.fetch', { status: response.status }));
return;
}
arrayBuffer = await response.arrayBuffer();
}

const parsedSlides = await parsePptx(arrayBuffer);
if (parsedSlides.length === 0) {
throw new Error('No slides found in presentation');
setError(t('preview.error.emptySlideshow'));
return;
}
setSlides(parsedSlides);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error parsing presentation';
setError(message);
} catch {
setError(t('preview.error.slideshowParse'));
} finally {
setLoading(false);
}
Expand Down
33 changes: 19 additions & 14 deletions app/shared/src/ui/TablePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ async function getXLSX(): Promise<typeof import('xlsx')> {

export const MAX_PREVIEW_FILE_BYTES = 20 * 1024 * 1024; // 20 MB

function assertPreviewSizeAllowed(byteLength: number): void {
if (byteLength > MAX_PREVIEW_FILE_BYTES) {
const actualMb = (byteLength / (1024 * 1024)).toFixed(1);
const limitMb = MAX_PREVIEW_FILE_BYTES / (1024 * 1024);
throw new Error(`文件过大(${actualMb} MB,上限 ${limitMb} MB),出于安全限制不预览`);
}
function previewSizeErrorValues(byteLength: number): { actualMb: string; limitMb: number } | null {
if (byteLength <= MAX_PREVIEW_FILE_BYTES) return null;
return {
actualMb: (byteLength / (1024 * 1024)).toFixed(1),
limitMb: MAX_PREVIEW_FILE_BYTES / (1024 * 1024),
};
}

export const TablePreview: React.FC<TablePreviewProps> = ({
Expand Down Expand Up @@ -126,12 +126,17 @@ export const TablePreview: React.FC<TablePreviewProps> = ({
} else {
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
setError(t('preview.error.fetch', { status: response.status }));
return;
}
arrayBuffer = await response.arrayBuffer();
}

assertPreviewSizeAllowed(arrayBuffer.byteLength);
const sizeError = previewSizeErrorValues(arrayBuffer.byteLength);
if (sizeError) {
setError(t('preview.error.tooLarge', sizeError));
return;
}

const XLSX = await getXLSX();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
Expand All @@ -140,13 +145,13 @@ export const TablePreview: React.FC<TablePreviewProps> = ({

const sheetName = names[0];
if (!sheetName) {
throw new Error('No sheets found in workbook');
setError(t('preview.error.emptyWorkbook'));
return;
}
setActiveSheet(sheetName);
parseSheet(workbook, sheetName);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error parsing spreadsheet';
setError(message);
} catch {
setError(t('preview.error.tableParse'));
} finally {
setLoading(false);
}
Expand Down Expand Up @@ -199,7 +204,7 @@ export const TablePreview: React.FC<TablePreviewProps> = ({
fileBlob
.arrayBuffer()
.then(async (ab) => {
assertPreviewSizeAllowed(ab.byteLength);
if (previewSizeErrorValues(ab.byteLength)) return;
const XLSX = await getXLSX();
const wb = XLSX.read(ab, { type: 'array' });
parseSheet(wb, sheetName);
Expand All @@ -221,7 +226,7 @@ export const TablePreview: React.FC<TablePreviewProps> = ({
response
.arrayBuffer()
.then(async (ab) => {
assertPreviewSizeAllowed(ab.byteLength);
if (previewSizeErrorValues(ab.byteLength)) return;
const XLSX = await getXLSX();
const wb = XLSX.read(ab, { type: 'array' });
parseSheet(wb, sheetName);
Expand Down
6 changes: 4 additions & 2 deletions app/web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ describe('Web app root', () => {

const { container } = render(<App />);
fireEvent.contextMenu(container.querySelector('[data-selectable-card="hub-message-1"]')!);
await screen.findByRole('menuitem', { name: 'context.copyLink' });
await screen.findByRole('menuitem', { name: 'context.delete' });
expect(screen.queryByRole('menuitem', { name: 'context.copyLink' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: 'context.regenerate' })).toBeNull();
expect(hubClientStub.regenerateAgentTask).not.toHaveBeenCalled();
});
Expand All @@ -323,7 +324,8 @@ describe('Web app root', () => {

const { container } = render(<App />);
fireEvent.contextMenu(container.querySelector('[data-selectable-card="hub-message-1"]')!);
await screen.findByRole('menuitem', { name: 'context.copyLink' });
await screen.findByRole('menuitem', { name: 'context.delete' });
expect(screen.queryByRole('menuitem', { name: 'context.copyLink' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: 'context.regenerate' })).toBeNull();
expect(hubClientStub.regenerateAgentTask).not.toHaveBeenCalled();
});
Expand Down
4 changes: 2 additions & 2 deletions app/web/src/hooks/useHubIMSnapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ describe('useHubIMSnapshot successors', () => {
},
], true, 'approved-real')).toMatchObject({
members: [
expect.objectContaining({ id: 'user-1', name: '产品', status: '在线' }),
expect.objectContaining({ id: 'user-2', name: 'Bob', status: '离线' }),
expect.objectContaining({ id: 'user-1', name: '产品' }),
expect.objectContaining({ id: 'user-2', name: 'Bob' }),
],
recentShortcuts: ['产品', 'Bob'],
});
Expand Down
2 changes: 1 addition & 1 deletion app/web/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function detectBrowserLanguage(): AppLanguage {
}

function getInitialLanguage(): AppLanguage {
return readStoredLanguage() ?? detectBrowserLanguage() ?? 'en';
return readStoredLanguage() ?? detectBrowserLanguage();
}


Expand Down
Loading
Loading