diff --git a/apps/desktop/src/main/applicationMenuLabels.ts b/apps/desktop/src/main/applicationMenuLabels.ts index 80fa4209f19..43afc58b131 100644 --- a/apps/desktop/src/main/applicationMenuLabels.ts +++ b/apps/desktop/src/main/applicationMenuLabels.ts @@ -47,7 +47,7 @@ export const APPLICATION_MENU_LABELS: Record { expect(result.githubEnhancementFailed).toBe(true); }); + describe('首屏快照写入', () => { + it('落地成功后写快照,只带 items 与身份', async () => { + const writeSnapshot = vi.fn(); + const service = new MyIssuesService( + makeDeps({ + now: () => Date.parse('2026-07-31T12:00:00.000Z'), + readLedger: () => [ledgerRecord()], + resolveGithubEnhancement: async () => GHOST_VIEWER, + searchAuthoredIssues: async () => ({ issues: [remoteIssue({ number: 7 })], totalCount: 1 }), + writeSnapshot, + }), + ); + + await service.list(); + expect(writeSnapshot).toHaveBeenCalledTimes(1); + const snapshot = writeSnapshot.mock.calls[0]![0]; + expect(snapshot.items.map((i: { number: number }) => i.number)).toEqual([7, 1001]); + expect(snapshot.githubEnhancement).toEqual({ login: 'octocat', source: 'ghost' }); + expect(snapshot.cachedAt).toBe('2026-07-31T12:00:00.000Z'); + // 「这一次查得怎么样」不进快照 —— 否则用户进页面就看到一条过期的错误提示。 + expect(snapshot).not.toHaveProperty('degraded'); + expect(snapshot).not.toHaveProperty('githubEnhancementFailed'); + expect(snapshot).not.toHaveProperty('truncated'); + }); + + it('落地时账号已切换 → 不写快照(结果本身也被拒绝交付)', async () => { + let scope = 'owner-a:1'; + const writeSnapshot = vi.fn(); + const service = new MyIssuesService( + makeDeps({ + readScope: () => scope, + fetchPlatformIssues: async () => { + scope = 'owner-b:2'; + return { ok: true as const, page: { issues: [remoteIssue()], totalCount: 1 } }; + }, + writeSnapshot, + }), + ); + + await expect(service.list()).rejects.toSatisfy(isStaleAccountScopeError); + // 快照按 owner 路径落盘,写进去就等于把 A 的 issue 塞进 B 的首屏。 + expect(writeSnapshot).not.toHaveBeenCalled(); + }); + + it('期间有提交成功(epoch 变了)→ 不写快照,与内存缓存同一判据', async () => { + const writeSnapshot = vi.fn(); + let release: (() => void) | null = null; + const gate = new Promise((resolve) => { + release = resolve; + }); + const service = new MyIssuesService( + makeDeps({ + fetchPlatformIssues: async () => { + await gate; + return { ok: true as const, page: { issues: [remoteIssue()], totalCount: 1 } }; + }, + writeSnapshot, + }), + ); + + const pending = service.list(); + service.invalidate(); // 提交成功 → 账本变了 + release!(); + await pending; + + // 落一份已知过时的首屏镜像没有收益(下次进页面反正要查)。 + expect(writeSnapshot).not.toHaveBeenCalled(); + }); + + it('写快照抛错不影响这一次查询的结果', async () => { + const service = new MyIssuesService( + makeDeps({ + readLedger: () => [ledgerRecord()], + writeSnapshot: () => { + throw new Error('ENOSPC: no space left on device'); + }, + }), + ); + + await expect(service.list()).resolves.toMatchObject({ + items: [expect.objectContaining({ number: 1001 })], + }); + }); + + it('没注入 writeSnapshot 时照常工作(快照是可选加速)', async () => { + const service = new MyIssuesService(makeDeps({ readLedger: () => [ledgerRecord()] })); + const result = await service.list(); + expect(result.items.map((i) => i.number)).toEqual([1001]); + }); + }); + describe('主通道搜不到时的兜底', () => { /** * 现实成因(实测):插件 PAT 是 fine-grained token,`get_current_user` 正常、搜本仓 diff --git a/apps/desktop/src/main/github-issue/__tests__/myIssuesSnapshotScope.test.ts b/apps/desktop/src/main/github-issue/__tests__/myIssuesSnapshotScope.test.ts new file mode 100644 index 00000000000..6ca1ec62d87 --- /dev/null +++ b/apps/desktop/src/main/github-issue/__tests__/myIssuesSnapshotScope.test.ts @@ -0,0 +1,109 @@ +/** + * 首屏快照的账号隔离回归。 + * + * 快照里有 issue 标题与 GitHub 用户名 —— 是账号私有数据,不是可共享的缓存。存储走 + * ownerScopedUserDataPath(),换号后必须读不到上一个账号的快照(否则切号瞬间的首屏会 + * 闪出别人的 issue 列表)。 + * + * 这里钉住「store 实例跟着 owner 路径重建」这一条 —— electron-store 被 mock,不碰真实 + * 文件系统(mock 形状照 submittedIssueLedgerScope.test.ts)。 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MyIssuesSnapshot } from '../../../shared/myIssues'; + +const ownerPathRef = { value: '/tmp/cindy-test-owner-a' }; +/** 按 owner 路径分桶,模拟真实的「每个账号一个目录」。 */ +const buckets: Record> = {}; + +vi.mock('../../appSessionState.js', () => ({ + ownerScopedUserDataPath: () => ownerPathRef.value, +})); + +vi.mock('electron-store', () => ({ + default: class FakeStore { + private readonly bucket: Record; + constructor(options: { cwd: string }) { + buckets[options.cwd] ??= {}; + this.bucket = buckets[options.cwd]!; + } + get(key: string, fallback: unknown) { + return this.bucket[key] ?? fallback; + } + set(key: string, value: unknown) { + this.bucket[key] = value; + } + }, +})); + +const { readMyIssuesSnapshot, writeMyIssuesSnapshot } = await import('../myIssuesSnapshotStore'); + +function snapshot(over: Partial = {}): MyIssuesSnapshot { + return { + items: [ + { + number: 1061, + url: 'https://github.com/makecindy/cindy/issues/1061', + title: '账号 A 的 issue 标题', + type: 'bug', + state: 'open', + createdAt: '2026-07-30T09:12:49.000Z', + updatedAt: null, + commentCount: null, + sources: ['cindy-tool'], + }, + ], + githubEnhancement: { login: 'owner-a-login', source: 'ghost' }, + cachedAt: '2026-07-31T12:00:00.000Z', + ...over, + }; +} + +/** + * 每个用例用一组**全新路径**。store 实例按 owner 路径缓存在模块级变量里,清空 buckets + * 并不会让它重建 —— 复用旧实例会读到一个已被移除的桶对象,用例之间互相污染。 + */ +let caseId = 0; +const ownerPath = (owner: 'a' | 'b') => `/tmp/cindy-test-${caseId}-owner-${owner}`; + +beforeEach(() => { + for (const key of Object.keys(buckets)) delete buckets[key]; + caseId += 1; + ownerPathRef.value = ownerPath('a'); +}); + +describe('首屏快照的账号隔离', () => { + it('同一账号内写了能读回来', () => { + writeMyIssuesSnapshot(snapshot()); + expect(readMyIssuesSnapshot()?.items.map((i) => i.number)).toEqual([1061]); + expect(readMyIssuesSnapshot()?.githubEnhancement?.login).toBe('owner-a-login'); + }); + + it('切到另一个账号后读不到上一个账号的快照', () => { + writeMyIssuesSnapshot(snapshot()); + expect(readMyIssuesSnapshot()).not.toBeNull(); + + ownerPathRef.value = ownerPath('b'); + // 账号 B 的首屏必须是干净的 —— 既不能看到 A 的标题,也不能看到 A 的 GitHub 用户名。 + expect(readMyIssuesSnapshot()).toBeNull(); + }); + + it('切回原账号仍能读到自己那份', () => { + writeMyIssuesSnapshot(snapshot()); + ownerPathRef.value = ownerPath('b'); + writeMyIssuesSnapshot(snapshot({ githubEnhancement: { login: 'owner-b-login', source: 'gh-cli' } })); + + ownerPathRef.value = ownerPath('a'); + expect(readMyIssuesSnapshot()?.githubEnhancement?.login).toBe('owner-a-login'); + }); + + it('账号 B 写入不会污染账号 A 的桶', () => { + writeMyIssuesSnapshot(snapshot()); + ownerPathRef.value = ownerPath('b'); + writeMyIssuesSnapshot(snapshot({ items: [] })); + + ownerPathRef.value = ownerPath('a'); + expect(readMyIssuesSnapshot()?.items).toHaveLength(1); + }); +}); diff --git a/apps/desktop/src/main/github-issue/__tests__/myIssuesSnapshotStore.test.ts b/apps/desktop/src/main/github-issue/__tests__/myIssuesSnapshotStore.test.ts new file mode 100644 index 00000000000..477f4b7684c --- /dev/null +++ b/apps/desktop/src/main/github-issue/__tests__/myIssuesSnapshotStore.test.ts @@ -0,0 +1,134 @@ +/** + * 首屏快照的清洗 —— 落盘文件是**不可信输入**(可被篡改、可能是旧版本写的)。 + * 判据与 payload 解析、账本清洗刻意保持一致:这一族在 #1103 / #1224 里反复漏过。 + * 只测纯函数,不碰 electron-store。 + */ + +import { describe, expect, it } from 'vitest'; + +import type { MyIssueItem } from '../../../shared/myIssues'; +import { normalizeSnapshot, normalizeSnapshotItems, __testing } from '../myIssuesSnapshotStore'; + +function item(over: Partial = {}): MyIssueItem { + const number = over.number ?? 1061; + return { + number, + url: `https://github.com/makecindy/cindy/issues/${number}`, + title: '标题', + type: 'bug', + state: 'open', + createdAt: '2026-07-30T09:12:49.000Z', + updatedAt: null, + commentCount: null, + sources: ['cindy-tool'], + ...over, + }; +} + +describe('normalizeSnapshotItems', () => { + it('正常条目原样保留', () => { + expect(normalizeSnapshotItems([item()])).toEqual([item()]); + }); + + it('链接一律按 number 派生,不采纳落盘的值', () => { + // 快照文件可被篡改,而这一页每行都声称「这是你在本仓提的 issue」、整行点击直接 + // 交给 openExternal。派生而非校验 —— 与 #1224 确立的「url 只有一个产出方式」一致。 + const [normalized] = normalizeSnapshotItems([ + item({ number: 42, url: 'https://evil.example.com/phish' }), + ]); + expect(normalized.url).toBe('https://github.com/makecindy/cindy/issues/42'); + }); + + it('丢掉形状不对的条目', () => { + const dropped = [ + null, + 'nope', + { ...item(), number: 0 }, + { ...item(), number: 1.5 }, + { ...item(), title: '' }, + // createdAt 不可解析 → 排序比较器会得到 NaN,让**整份**列表顺序未定义 + { ...item(), createdAt: 'not-a-date' }, + { ...item(), state: 'reopened' }, + // sources 全非法 ⇒ 无法标注来源,不如不显示 + { ...item(), sources: ['made-up'] }, + { ...item(), sources: [] }, + ]; + expect(normalizeSnapshotItems(dropped)).toEqual([]); + }); + + it('可选字段坏掉时降级为 null,不整条丢弃', () => { + const [normalized] = normalizeSnapshotItems([ + item({ type: 'question' as never, updatedAt: 'nope', commentCount: 'lots' as never }), + ]); + expect(normalized).toMatchObject({ type: null, updatedAt: null, commentCount: null }); + }); + + it('只保留合法的来源,顺序按既有约定', () => { + const [normalized] = normalizeSnapshotItems([ + item({ sources: ['github-account', 'nonsense', 'cindy-tool'] as never }), + ]); + expect(normalized.sources).toEqual(['cindy-tool', 'github-account']); + }); + + it('总量压在上限内 —— 首屏只需要看得见的那一段', () => { + const many = Array.from({ length: __testing.MAX_SNAPSHOT_ITEMS + 50 }, (_, i) => + item({ number: i + 1 }), + ); + expect(normalizeSnapshotItems(many)).toHaveLength(__testing.MAX_SNAPSHOT_ITEMS); + }); + + it('非数组输入返回空列表', () => { + expect(normalizeSnapshotItems(undefined)).toEqual([]); + expect(normalizeSnapshotItems({ items: [] })).toEqual([]); + }); +}); + +describe('normalizeSnapshot', () => { + it('完整快照原样通过', () => { + const snapshot = { + items: [item()], + githubEnhancement: { login: 'octocat', source: 'ghost' as const }, + cachedAt: '2026-07-31T12:00:00.000Z', + }; + expect(normalizeSnapshot(snapshot)).toEqual(snapshot); + }); + + it('cachedAt 缺失或不可解析时当作没有快照', () => { + for (const bad of [undefined, '', 'yesterday', 123]) { + expect(normalizeSnapshot({ items: [item()], cachedAt: bad })).toBeNull(); + } + }); + + it('身份形状不对时降级为 null,但条目照常保留', () => { + const result = normalizeSnapshot({ + items: [item()], + githubEnhancement: { login: '', source: 'ghost' }, + cachedAt: '2026-07-31T12:00:00.000Z', + }); + expect(result?.githubEnhancement).toBeNull(); + expect(result?.items).toHaveLength(1); + + const badSource = normalizeSnapshot({ + items: [], + githubEnhancement: { login: 'octocat', source: 'carrier-pigeon' }, + cachedAt: '2026-07-31T12:00:00.000Z', + }); + expect(badSource?.githubEnhancement).toBeNull(); + }); + + it('null / 非对象一律当没有快照', () => { + expect(normalizeSnapshot(null)).toBeNull(); + expect(normalizeSnapshot('nope')).toBeNull(); + expect(normalizeSnapshot(undefined)).toBeNull(); + }); + + it('空列表的快照是合法的 —— 但它不代表「查证过没有」', () => { + // 语义在 useMyIssues 的 hasFreshData 那一层收口:快照顶上来时不下任何结论。 + const result = normalizeSnapshot({ + items: [], + githubEnhancement: null, + cachedAt: '2026-07-31T12:00:00.000Z', + }); + expect(result).toEqual({ items: [], githubEnhancement: null, cachedAt: '2026-07-31T12:00:00.000Z' }); + }); +}); diff --git a/apps/desktop/src/main/github-issue/myIssuesRuntime.ts b/apps/desktop/src/main/github-issue/myIssuesRuntime.ts index 24448579c2a..3de4ce4653e 100644 --- a/apps/desktop/src/main/github-issue/myIssuesRuntime.ts +++ b/apps/desktop/src/main/github-issue/myIssuesRuntime.ts @@ -16,7 +16,11 @@ import { GithubClient } from '@cindy/github-client'; -import { MY_ISSUES_REPOSITORY, type MyIssuesDegradedReason } from '../../shared/myIssues.js'; +import { + MY_ISSUES_REPOSITORY, + type MyIssuesDegradedReason, + type MyIssuesSnapshot, +} from '../../shared/myIssues.js'; import { getAppCapabilities } from '../appCapabilities.js'; import { activeOwnerScopeKey } from '../appSessionState.js'; import { getClientEndpoint } from '../clientEndpointsService'; @@ -39,6 +43,7 @@ import { type RemoteIssuePage, } from './myIssuesService.js'; import { listSubmittedIssues } from './submittedIssueLedger.js'; +import { readMyIssuesSnapshot, writeMyIssuesSnapshot } from './myIssuesSnapshotStore.js'; const log = createLogger('github-issue/my-issues-runtime'); @@ -74,12 +79,18 @@ export function getMyIssuesService(): MyIssuesService { resolveGithubEnhancement: resolveGithubEnhancement, searchAuthoredIssues: searchAuthoredIssues, searchAuthoredIssuesFallback: searchAuthoredIssuesFallback, + writeSnapshot: writeMyIssuesSnapshot, readScope: activeOwnerScopeKey, }); } return serviceInstance; } +/** 首屏快照:进页面先渲染上次结果,不用空等远端。没有 / 坏掉返回 null。 */ +export function getMyIssuesSnapshot(): MyIssuesSnapshot | null { + return readMyIssuesSnapshot(); +} + /** 提交成功后让列表缓存立即失效,不然新提交的那条最多要等 60s 才出现。 */ export function invalidateMyIssuesCache(): void { serviceInstance?.invalidate(); diff --git a/apps/desktop/src/main/github-issue/myIssuesService.ts b/apps/desktop/src/main/github-issue/myIssuesService.ts index 9a8b27bd795..13ef360377f 100644 --- a/apps/desktop/src/main/github-issue/myIssuesService.ts +++ b/apps/desktop/src/main/github-issue/myIssuesService.ts @@ -22,6 +22,7 @@ import type { MyIssueSource, MyIssuesDegradedReason, MyIssuesResult, + MyIssuesSnapshot, SubmittedIssueRecord, } from '../../shared/myIssues.js'; import { myIssueUrl } from '../../shared/myIssues.js'; @@ -99,6 +100,11 @@ export interface MyIssuesServiceDeps { * 「主通道失败必须换通道再试」这条不变量只有放这一层才钉得住。 */ searchAuthoredIssuesFallback?: (login: string) => Promise; + /** + * 落地成功后写首屏快照(下次进页面先渲染它,不用空等远端)。 + * 注入而非直接 import,是为了让本模块保持 electron-free、单测不碰磁盘。 + */ + writeSnapshot?: (snapshot: MyIssuesSnapshot) => void; /** * 当前账号作用域标识(data owner + session generation)。**这是安全边界**: * issue 列表含标题、编号与 GitHub 用户名,属于账号私有数据。服务是进程级单例, @@ -207,10 +213,32 @@ export class MyIssuesService { } if (this.cacheEpoch === epochAtStart) { this.cache = { at: this.now(), scope, result }; + // 落盘快照与内存缓存**同一个判据**,不为它另立一套:epoch 变了说明期间有提交成功过, + // 落一份已知过时的首屏镜像没有收益(下次进页面反正要查)。刻意只带 items 与身份 —— + // degraded / failed / truncated 是「这一次查得怎么样」,缓存它们会让用户进页面 + // 就看到过期的错误提示。 + this.persistSnapshot(result); } return result; } + /** 快照是 best-effort 的首屏加速:写失败只记日志,绝不能把一次成功的查询翻成失败。 */ + private persistSnapshot(result: MyIssuesResult): void { + const write = this.deps.writeSnapshot; + if (!write) return; + try { + write({ + items: result.items, + githubEnhancement: result.githubEnhancement, + cachedAt: new Date(this.now()).toISOString(), + }); + } catch (err) { + log.warn('writing the my-issues snapshot failed', { + error: err instanceof Error ? err.message : String(err), + }); + } + } + /** * 提交成功后调用:账本变了,缓存立即失效,下次进页面能看到新提交的那条。 * 递增 epoch 是关键 —— 只清 cache 挡不住「早于本次提交发起、晚于本次提交完成」 diff --git a/apps/desktop/src/main/github-issue/myIssuesSnapshotStore.ts b/apps/desktop/src/main/github-issue/myIssuesSnapshotStore.ts new file mode 100644 index 00000000000..61e82e810d5 --- /dev/null +++ b/apps/desktop/src/main/github-issue/myIssuesSnapshotStore.ts @@ -0,0 +1,164 @@ +/** + * 「我的 Issue」首屏快照的落盘存储。 + * + * 为什么需要它:列表要等平台通道与 GitHub 增强都落地才出现(增强走插件失败 + gh CLI + * 回退时实测约 2s),而 service 那层的 60s TTL 缓存是**内存**的 —— 进程一重启就没了, + * 首次进页面必然 miss。于是每次冷启动进 /issues 都要空等几秒。存一份上次的结果, + * 进页面立刻有内容可读。 + * + * 语义边界(与 device-link/mirrorCacheStore 同构,那套注释直接适用): + * - 快照是**可重建的首屏镜像,不是真相**。远端仍是唯一真相源,fresh 一到即整体接管。 + * - **不缓存「这一次查得怎么样」**(degraded / enhancementFailed / truncated): + * 那是本次查询的健康状况,缓存它等于让用户进页面就看到一条过期的错误提示。 + * - 快照里的空列表**不构成**「查证过的空」,不能推出「你从未提交」(见 MyIssuesSnapshot)。 + * + * 为什么用 electron-store 而不照搬 mirrorCacheStore 的那套 IO:后者的 purge 队列、 + * 跨进程锁、作废屏障是为多设备消息文件与内联媒体设计的;这里只是「一个数组 + 上限 + + * 校验」,与同目录的 submittedIssueLedger 同构,照它的形状写就够。 + * + * 存储位置走 ownerScopedUserDataPath():按 Cindy 账号天然隔离,换号 / 登出后读不到 + * 旧账号的 issue 标题与 GitHub 用户名(这是账号私有数据,不是可共享的缓存)。 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import Store from 'electron-store'; + +import type { MyIssueItem, MyIssueSource, MyIssuesSnapshot } from '../../shared/myIssues.js'; +import { myIssueUrl } from '../../shared/myIssues.js'; +import { ownerScopedUserDataPath } from '../appSessionState.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('github-issue/my-issues-snapshot'); + +interface SnapshotShape { + snapshot: MyIssuesSnapshot | null; +} + +/** + * 首屏只需要看得见的那一段 —— fresh 一到就整体接管,存更多只是让文件变大。 + * (合并后的 items 最坏可达账本 500 + 一页 100。) + */ +const MAX_SNAPSHOT_ITEMS = 200; + +const VALID_STATES = new Set(['open', 'closed', 'unknown']); +const VALID_SOURCES: MyIssueSource[] = ['cindy-tool', 'github-account']; + +let storeInstance: Store | null = null; +let storePath: string | null = null; + +function getStore(): Store { + const currentPath = ownerScopedUserDataPath(); + if (!storeInstance || storePath !== currentPath) { + storeInstance = new Store({ + name: 'my-issues-snapshot', + cwd: currentPath, + defaults: { snapshot: null }, + clearInvalidConfig: true, + }); + storePath = currentPath; + } + return storeInstance; +} + +/** + * 清洗**读出来的**条目并返回,不回写 —— 落盘的坏数据不会被自动修好,每次读都重新过滤。 + * + * 判据与既有三处保持一致(这一族在 #1103 / #1224 里反复漏过,所以照抄判据而不是另立): + * - `url` **一律按 number 派生**,不采纳落盘的值。文件可被篡改,而这一页每行都声称 + * 「这是你在本仓提的 issue」、整行点击直接交给 openExternal。 + * - `createdAt` 必须可被 Date.parse 解析:列表排序直接拿它相减,NaN 会让**整份**顺序 + * 变成未定义(不是「这一条排错位置」)。 + * - `state` / `sources` 只收合法值,免得渲染出不存在的状态点或来源标记。 + * + * 纯函数,单测直接调(不碰 electron-store)。 + */ +export function normalizeSnapshotItems(value: unknown): MyIssueItem[] { + if (!Array.isArray(value)) return []; + const items: MyIssueItem[] = []; + for (const raw of value) { + if (!raw || typeof raw !== 'object') continue; + const candidate = raw as Partial; + const { number, title, createdAt } = candidate; + if (typeof number !== 'number' || !Number.isInteger(number) || number <= 0) continue; + if (typeof title !== 'string' || title.length === 0) continue; + if (typeof createdAt !== 'string' || !Number.isFinite(Date.parse(createdAt))) continue; + if (typeof candidate.state !== 'string' || !VALID_STATES.has(candidate.state)) continue; + + const sources = Array.isArray(candidate.sources) + ? VALID_SOURCES.filter((source) => candidate.sources!.includes(source)) + : []; + if (sources.length === 0) continue; + + items.push({ + number, + // 派生,不信落盘值 —— 理由见上面的判据说明。 + url: myIssueUrl(number), + title, + type: candidate.type === 'bug' || candidate.type === 'feature' ? candidate.type : null, + state: candidate.state as MyIssueItem['state'], + createdAt, + updatedAt: + typeof candidate.updatedAt === 'string' && Number.isFinite(Date.parse(candidate.updatedAt)) + ? candidate.updatedAt + : null, + commentCount: + typeof candidate.commentCount === 'number' && Number.isFinite(candidate.commentCount) + ? candidate.commentCount + : null, + sources, + }); + if (items.length >= MAX_SNAPSHOT_ITEMS) break; + } + return items; +} + +/** 清洗整份快照;形状不对(含 null)一律当「没有快照」。 */ +export function normalizeSnapshot(value: unknown): MyIssuesSnapshot | null { + if (!value || typeof value !== 'object') return null; + const candidate = value as Partial; + const items = normalizeSnapshotItems(candidate.items); + const cachedAt = + typeof candidate.cachedAt === 'string' && Number.isFinite(Date.parse(candidate.cachedAt)) + ? candidate.cachedAt + : null; + if (!cachedAt) return null; + + const enhancement = candidate.githubEnhancement; + const githubEnhancement = + enhancement && + typeof enhancement === 'object' && + typeof enhancement.login === 'string' && + enhancement.login.length > 0 && + (enhancement.source === 'ghost' || enhancement.source === 'gh-cli') + ? { login: enhancement.login, source: enhancement.source } + : null; + + return { items, githubEnhancement, cachedAt }; +} + +/** 读首屏快照;没有 / 坏掉都返回 null,调用方按「首次使用」处理。 */ +export function readMyIssuesSnapshot(): MyIssuesSnapshot | null { + try { + return normalizeSnapshot(getStore().get('snapshot', null)); + } catch (err) { + // 读不到快照只是少了首屏加速,绝不能影响这一页能不能用。 + log.warn('reading the my-issues snapshot failed; treating it as absent', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** + * 写首屏快照。调用方(service 的 settle)已经校验过账号作用域 —— 这里只负责落盘。 + * 抛错交给调用方吞掉:快照写不进去不该让一次成功的查询变成失败。 + */ +export function writeMyIssuesSnapshot(snapshot: MyIssuesSnapshot): void { + getStore().set('snapshot', { + items: snapshot.items.slice(0, MAX_SNAPSHOT_ITEMS), + githubEnhancement: snapshot.githubEnhancement, + cachedAt: snapshot.cachedAt, + }); +} + +export const __testing = { MAX_SNAPSHOT_ITEMS }; diff --git a/apps/desktop/src/main/maker-ipc/channels.ts b/apps/desktop/src/main/maker-ipc/channels.ts index b4edf488a62..3322971c198 100644 --- a/apps/desktop/src/main/maker-ipc/channels.ts +++ b/apps/desktop/src/main/maker-ipc/channels.ts @@ -226,6 +226,11 @@ export const MAKER_INVOKE = { * 风格而不是 throwIpcError(见 engineering-conventions §2 的例外)。 */ MY_ISSUES_LIST: 'maker:issues:list-mine', + /** + * /issues 的**首屏快照**(上次查询成功时落盘的列表镜像)。进页面先渲染它, + * 避免空等远端;fresh 一到即整体接管。非权威、可重建,详见 myIssuesSnapshotStore。 + */ + MY_ISSUES_SNAPSHOT: 'maker:issues:snapshot-mine', WRITE_PLAN_FILE: 'maker:write-plan-file', // Rewind / Fork (Stage 2 C2) — 取代老 cc-agent:rewind:* + local-db:sessions:fork REWIND_PREVIEW: 'maker:rewind:preview', diff --git a/apps/desktop/src/main/maker-ipc/my-issues.ts b/apps/desktop/src/main/maker-ipc/my-issues.ts index d1f79c28af5..f67a8543cad 100644 --- a/apps/desktop/src/main/maker-ipc/my-issues.ts +++ b/apps/desktop/src/main/maker-ipc/my-issues.ts @@ -10,8 +10,12 @@ import { ipcMain } from 'electron'; -import type { MyIssuesErrorCode, MyIssuesResult } from '../../shared/myIssues.js'; -import { getMyIssuesService } from '../github-issue/myIssuesRuntime.js'; +import type { + MyIssuesErrorCode, + MyIssuesResult, + MyIssuesSnapshot, +} from '../../shared/myIssues.js'; +import { getMyIssuesService, getMyIssuesSnapshot } from '../github-issue/myIssuesRuntime.js'; import { isStaleAccountScopeError } from '../github-issue/myIssuesService.js'; import { createLogger } from '../logger.js'; import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; @@ -32,6 +36,10 @@ export type MyIssuesListResponse = truncated: false; }; +export interface MyIssuesSnapshotDeps { + read: () => MyIssuesSnapshot | null; +} + export interface MyIssuesListDeps { list: (options: { force?: boolean }) => Promise; } @@ -70,7 +78,32 @@ export async function handleMyIssuesList( } } +/** + * 首屏快照读取。与 list 分开一条 channel 而不是给 list 加 `cachedOnly` 参数: + * 那样得再引入一个字段区分「缓存是空列表」与「根本没有缓存」,把 list 的契约搞混。 + * + * 失败一律当「没有快照」返回 null —— 首屏加速拿不到只是回到旧体验,不该变成错误态。 + */ +export async function handleMyIssuesSnapshot( + deps: MyIssuesSnapshotDeps = { read: getMyIssuesSnapshot }, +): Promise { + try { + return deps.read(); + } catch (err) { + log.warn('reading the my-issues snapshot failed', { + detail: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + export function registerMyIssuesIpc(): void { + ipcMain.handle(MAKER_INVOKE.MY_ISSUES_SNAPSHOT, (event) => { + // 快照含 issue 标题与 GitHub 用户名,与 list 同属账号私有数据 —— 同一道来源闸。 + assertTrustedAppRendererEvent(event); + return handleMyIssuesSnapshot(); + }); + ipcMain.handle(MAKER_INVOKE.MY_ISSUES_LIST, (event, raw: unknown) => { // issue 列表含标题、编号与 GitHub 用户名,是账号私有数据,且这条 handler 会代为 // 发起带登录态的平台请求。只允许 Cindy 自有顶层页面调用:WebView、Ghost 页面、 diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 81dbeb2db61..083518b7a03 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -4808,6 +4808,9 @@ contextBridge.exposeInMainWorld('electronAPI', { input: import('../shared/helpTypes').HelpFeedbackDraftInput, ): Promise => ipcRenderer.invoke('maker:help:feedback:create', input), + // /issues 首屏快照(上次结果的落盘镜像);没有 / 坏掉返回 null。非权威,fresh 一到即接管。 + getMyIssuesSnapshot: (): Promise => + ipcRenderer.invoke('maker:issues:snapshot-mine'), // /issues 页面的「我的 Issue」列表;force=true 绕过 main 侧 60s TTL(手动刷新)。 listMyIssues: ( options?: { force?: boolean }, diff --git a/apps/desktop/src/renderer/components/title-bar/MenuButton.tsx b/apps/desktop/src/renderer/components/title-bar/MenuButton.tsx index 5d09ecdef2e..3f40602b23e 100644 --- a/apps/desktop/src/renderer/components/title-bar/MenuButton.tsx +++ b/apps/desktop/src/renderer/components/title-bar/MenuButton.tsx @@ -49,9 +49,14 @@ export function MenuButton() { > {t('titleBar.menuItems.help')} - {/* Issues 入口:与 macOS 系统菜单「帮助 → 议题」等价,但此处常驻应用内左上角 + {/* Issue 页入口:与 macOS 系统菜单「帮助 → 问题反馈」等价,但此处常驻应用内左上角 菜单,Windows / Linux(无系统菜单)也能访问 —— 否则非 mac 平台无可见入口。 - navigate('/issues') 与 MainLayout 的 'open-issues' 系统菜单命令同一行为。 */} + navigate('/issues') 与 MainLayout 的 'open-issues' 系统菜单命令同一行为。 + + 标签**刻意不是术语**「Issue」:菜单项回答的是「点这里能干什么」,而它的邻居 + 全是动作短语(帮助 / 检查更新);夹一个英文名词既断了风格,也让不熟悉 GitHub + 的用户不知道该不该点。术语裁决(i18n/GLOSSARY.md)管的是指代那类对象的位置 + —— 页面标题与正文仍写 Issue,因为点进去就跳 GitHub,名字必须对得上。 */} { diff --git a/apps/desktop/src/renderer/features/issue-tracker/IssueTrackerFeatureLayout.tsx b/apps/desktop/src/renderer/features/issue-tracker/IssueTrackerFeatureLayout.tsx index 6faba3822ae..605ad021fcc 100644 --- a/apps/desktop/src/renderer/features/issue-tracker/IssueTrackerFeatureLayout.tsx +++ b/apps/desktop/src/renderer/features/issue-tracker/IssueTrackerFeatureLayout.tsx @@ -10,10 +10,13 @@ * * 一条都没有时,页面退回原来的引导形态(告诉用户怎么用 /issue 提交)。 * - * **取数期间不换界面**(engineering-conventions §7):首屏加载不显示 loading 文案, - * 正文保持引导内容 —— 平台通道的总 deadline 可达 12s,换成一行「加载中」会造成 - * 引导 → loading → 列表 两次跳变;而一条都没有的用户(最常见)看到的引导页更是 - * 从头到尾不该动过。进度反馈只放在 header 的刷新图标上(零布局变化)。 + * **取数期间不换界面**(engineering-conventions §7),分两层做到: + * 1. 有落盘快照时首屏直接渲染上次的列表(useMyIssues 的 hydrate),内容立刻可读可点, + * fresh 一到原子替换 —— 常见路径上根本不存在「等待态」。 + * 2. 没有快照(首次使用)时保留引导正文,但**不显示标题** —— 标题是结论,而这时还没查完。 + * 上一版在这里显示「暂时查不到你的 Issue」,于是 GitHub 上有几十条的用户先被告知 + * 查不到、再跳成列表:比 loading 文案更糟,因为它是错的。 + * 进度反馈一律只放在 header 的刷新图标上(零布局变化)。 * * 左侧 app 侧栏沿用 cc-agent 项目/对话列表(显式注册,避免冷启动直接进 /issues * 时左栏空白,详见 useRegisterCCAgentSidebar)。 @@ -41,7 +44,7 @@ export function IssueTrackerFeatureLayout() { const navigate = useNavigate(); // 沿用 cc-agent 侧栏;冷启动直接进 /issues 时也能播种,不留空白左栏。 useRegisterCCAgentSidebar(); - const { data, loading, refreshing, error, refresh } = useMyIssues(); + const { data, hasFreshData, loading, refreshing, error, refresh } = useMyIssues(); const items = data?.items ?? []; const hasItems = items.length > 0; @@ -102,7 +105,7 @@ export function IssueTrackerFeatureLayout() { ) : ( <> - + )} @@ -271,22 +274,29 @@ function SubmitHintBar({ onStartIssueChat }: { onStartIssueChat: () => void }) { function EmptyGuide({ onStartIssueChat, data, + hasFreshData, }: { onStartIssueChat: () => void; data: MyIssuesResult | null; + hasFreshData: boolean; }) { const { t } = useTranslation(); - // data 为 null = 首屏还没拿到结果,同样不能断言「没有提交过」。 - const trustable = data ? canTrustEmptyList(data) : false; + // 这一轮还没查完(或数据来自落盘快照)时**整个标题不渲染**:引导正文与 CTA 无论有没有 + // issue 都成立,标题却是个结论。上一版在这里显示「暂时查不到你的 Issue」,于是有 35 条 + // 的用户先被告知查不到、再跳成列表 —— 比 loading 文案更糟,因为它是错的。 + // 标题从无到有是内容增加,不是形态替换。 + const trustable = hasFreshData && data ? canTrustEmptyList(data) : false; return (
-

- {t(trustable ? 'issueTracker.mine.emptyTitle' : 'issueTracker.mine.emptyTitleUnverified')} -

+ {hasFreshData ? ( +

+ {t(trustable ? 'issueTracker.mine.emptyTitle' : 'issueTracker.mine.emptyTitleUnverified')} +

+ ) : null}

{t('issueAgent.redirect.descriptionBefore')} diff --git a/apps/desktop/src/renderer/features/issue-tracker/__tests__/IssueTrackerFeatureLayout.test.tsx b/apps/desktop/src/renderer/features/issue-tracker/__tests__/IssueTrackerFeatureLayout.test.tsx index e35e57d42ad..215d061555c 100644 --- a/apps/desktop/src/renderer/features/issue-tracker/__tests__/IssueTrackerFeatureLayout.test.tsx +++ b/apps/desktop/src/renderer/features/issue-tracker/__tests__/IssueTrackerFeatureLayout.test.tsx @@ -62,9 +62,14 @@ function result(items: MyIssueItem[], over: Partial = {}): MyIss }; } +/** + * 默认 hasFreshData=true("这一轮查过了"),因为多数用例断言的是查完之后的形态。 + * 首屏 / 快照顶上来的场景要显式传 false。 + */ function state(over: Record = {}) { return { data: null, + hasFreshData: true, loading: false, refreshing: false, error: null, @@ -122,27 +127,52 @@ describe('IssueTrackerFeatureLayout 内容区分支', () => { it('首屏取数期间保留引导内容,不换成 loading 文案(engineering-conventions §7)', () => { // 平台通道总 deadline 可达 12s。换成一行「加载中」会造成 引导 → loading → 列表 // 两次跳变;而一条都没有的用户(最常见)看到的引导页本该从头到尾没动过。 - useMyIssuesMock.mockReturnValue(state({ loading: true, data: null })); + useMyIssuesMock.mockReturnValue( + state({ loading: true, data: null, hasFreshData: false }), + ); render(); - // 引导正文在场(那段「怎么提交」的说明),但标题用的是「查不到」那版 —— - // 首屏还没拿到结果,同样不能断言「还没有提交过」。 - expect(screen.getByText('issueTracker.mine.emptyTitleUnverified')).toBeTruthy(); + // 引导正文与 CTA 在场,但**一个标题都不渲染** —— 标题是结论,这时还没查完。 + // 上一版这里显示「暂时查不到你的 Issue」,GitHub 上有几十条的用户会先被这句话骗一次。 + expect(screen.getByRole('button', { name: 'issueTracker.mine.startIssueChat' })).toBeTruthy(); expect(screen.queryByText('issueTracker.mine.emptyTitle')).toBeNull(); + expect(screen.queryByText('issueTracker.mine.emptyTitleUnverified')).toBeNull(); expect(screen.queryByText('issueTracker.detail.loading')).toBeNull(); }); + it('落盘快照顶上来时同样不下结论 —— 快照的空列表不算查证过', () => { + // useMyIssues 用快照合成 data(degraded/failed 都填「没问题」),但 hasFreshData 仍为 + // false。若标题只看 canTrustEmptyList,这里会冒出「还没有提交过 Issue」。 + useMyIssuesMock.mockReturnValue(state({ data: result([]), hasFreshData: false })); + render(); + + expect(screen.queryByText('issueTracker.mine.emptyTitle')).toBeNull(); + expect(screen.queryByText('issueTracker.mine.emptyTitleUnverified')).toBeNull(); + }); + it('首屏取数完成后引导原子切成列表 —— 中间不经过第三种形态', () => { - useMyIssuesMock.mockReturnValue(state({ loading: true, data: null })); + useMyIssuesMock.mockReturnValue( + state({ loading: true, data: null, hasFreshData: false }), + ); const view = render(); - expect(screen.getByText('issueTracker.mine.emptyTitleUnverified')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'issueTracker.mine.startIssueChat' })).toBeTruthy(); useMyIssuesMock.mockReturnValue(state({ loading: false, data: result([item()]) })); view.rerender(); - expect(screen.queryByText('issueTracker.mine.emptyTitleUnverified')).toBeNull(); expect(screen.getByText('已经加载出来的那条 issue')).toBeTruthy(); }); + it('有快照时首屏直接出列表 —— 常见路径上不存在等待态', () => { + useMyIssuesMock.mockReturnValue( + state({ loading: true, data: result([item()]), hasFreshData: false }), + ); + render(); + + expect(screen.getByText('已经加载出来的那条 issue')).toBeTruthy(); + // 快照态不显示任何降级提示(快照刻意不含本次查询的健康状况)。 + expect(screen.queryByText('issueTracker.mine.platformUnavailableHint')).toBeNull(); + }); + it('空列表:三路都查询成功才敢说「还没有提交过」', () => { useMyIssuesMock.mockReturnValue(state({ data: result([]) })); render(); diff --git a/apps/desktop/src/renderer/features/issue-tracker/hooks/useMyIssues.ts b/apps/desktop/src/renderer/features/issue-tracker/hooks/useMyIssues.ts index c24f79e84e0..0d295099549 100644 --- a/apps/desktop/src/renderer/features/issue-tracker/hooks/useMyIssues.ts +++ b/apps/desktop/src/renderer/features/issue-tracker/hooks/useMyIssues.ts @@ -4,17 +4,33 @@ * 进页面拉一次,之后只由用户点刷新触发(**禁** setInterval 轮询;main 侧本身有 * 60s TTL 缓存,重复进页面不会真去打 GitHub)。刷新期间保留旧数据,拿到新数据再 * 原子替换,不出现空白帧。 + * + * 首屏先读落盘快照(上次结果),立刻有内容可读 —— 列表要等平台通道与 GitHub 增强都 + * 落地才出得来,实测约 2s,而 service 那层的 TTL 缓存是内存的、冷启动必然 miss。 + * + * **fresh 与快照分开存**,不要把快照塞进同一个 data:快照里的空列表只说明上次没查到, + * 不能推出「你从未提交」。空态标题只认这一轮真查过的结果(hasFreshData)。 */ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { MyIssuesErrorCode, MyIssuesResult } from '@/../shared/myIssues'; +import type { + MyIssuesErrorCode, + MyIssuesResult, + MyIssuesSnapshot, +} from '@/../shared/myIssues'; import { createLogger } from '@/lib/logger'; const log = createLogger('useMyIssues'); export interface UseMyIssuesState { + /** 视图数据:优先本轮 fresh,还没到就用快照顶着。 */ data: MyIssuesResult | null; + /** + * 这一轮是否已经真查过。**空态文案必须看它** —— 快照顶上来的数据不构成 + * 「查证过的空」,false 时不得下任何「有没有 issue」的结论。 + */ + hasFreshData: boolean; /** 首屏加载中(有数据后的刷新走 refreshing,不让列表闪成骨架屏)。 */ loading: boolean; refreshing: boolean; @@ -26,8 +42,23 @@ export interface UseMyIssuesState { refresh: () => void; } +/** + * 把快照补成视图用的 result。缺的三个字段一律填「没问题」—— + * 快照刻意不含本次查询的健康状况,所以缓存态下 notices 自然什么都不显示。 + */ +function snapshotAsResult(snapshot: MyIssuesSnapshot): MyIssuesResult { + return { + items: snapshot.items, + githubEnhancement: snapshot.githubEnhancement, + githubEnhancementFailed: false, + degraded: null, + truncated: false, + }; +} + export function useMyIssues(): UseMyIssuesState { - const [data, setData] = useState(null); + const [fresh, setFresh] = useState(null); + const [snapshot, setSnapshot] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); @@ -46,7 +77,7 @@ export function useMyIssues(): UseMyIssuesState { const response = await window.electronAPI.maker.listMyIssues({ force }); if (disposed.current) return; if (response.success) { - setData({ + setFresh({ items: response.items, githubEnhancement: response.githubEnhancement, githubEnhancementFailed: response.githubEnhancementFailed, @@ -77,6 +108,22 @@ export function useMyIssues(): UseMyIssuesState { useEffect(() => { disposed.current = false; + // 快照与真实查询**并行发起**,不要 await 它再查:快照只是首屏占位, + // 让它挡在查询前面等于把最慢那条路又加长一点。 + void (async () => { + try { + const cached = await window.electronAPI.maker.getMyIssuesSnapshot(); + // fresh 已经先到就别再拿旧快照盖回去(快照读得慢时会发生)。 + if (!disposed.current && cached) { + setSnapshot((prev) => prev ?? cached); + } + } catch (err) { + // 读不到快照只是回到「空等几秒」的旧体验,不影响这一页能不能用。 + log.warn('reading the my-issues snapshot failed', { + error: err instanceof Error ? err.message : String(err), + }); + } + })(); void load(false); return () => { disposed.current = true; @@ -87,5 +134,6 @@ export function useMyIssues(): UseMyIssuesState { void load(true); }, [load]); - return { data, loading, refreshing, error, refresh }; + const data = fresh ?? (snapshot ? snapshotAsResult(snapshot) : null); + return { data, hasFreshData: fresh !== null, loading, refreshing, error, refresh }; } diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index b322e327958..76cf16a162f 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -3672,7 +3672,7 @@ "close": "閉じる", "menuItems": { "help": "ヘルプ", - "issues": "問題", + "issues": "フィードバック", "checkForUpdates": "更新を確認" }, "updateCheckToast": { diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 7603e5f5482..4858e1b712c 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -3672,7 +3672,7 @@ "close": "关闭", "menuItems": { "help": "帮助", - "issues": "Issue", + "issues": "问题反馈", "checkForUpdates": "检查更新" }, "updateCheckToast": { diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 4d70e61abf4..52b147c392e 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -4465,6 +4465,14 @@ interface ElectronAPI { input: import('../shared/helpTypes').HelpFeedbackDraftInput, ) => Promise; /** /issues 页面的「我的 Issue」列表;force=true 绕过 main 侧 60s TTL(手动刷新)。 */ + /** + * /issues 首屏快照(上次查询成功时落盘的列表镜像)。进页面先渲染它,避免空等远端; + * fresh 一到即整体接管。**非权威**:里面没有本次查询的健康状况,它的空列表也不构成 + * 「查证过的空」(详见 main/github-issue/myIssuesSnapshotStore.ts)。 + */ + getMyIssuesSnapshot: () => Promise< + import('../shared/myIssues').MyIssuesSnapshot | null + >; listMyIssues: (options?: { force?: boolean }) => Promise< | ({ success: true } & import('../shared/myIssues').MyIssuesResult) | { diff --git a/apps/desktop/src/shared/myIssues.ts b/apps/desktop/src/shared/myIssues.ts index 12248a2e2b3..142cb1d6804 100644 --- a/apps/desktop/src/shared/myIssues.ts +++ b/apps/desktop/src/shared/myIssues.ts @@ -109,6 +109,23 @@ export interface MyIssuesResult { truncated: boolean; } +/** + * 首屏占位快照 —— 上一次查询成功时落盘的列表镜像,进页面立刻渲染它,fresh 一到即整体 + * 接管(语义同 device-link/mirrorCacheStore:**可重建的镜像,不是真相**)。 + * + * **刻意不含** degraded / githubEnhancementFailed / truncated:那三个描述的是「这一次 + * 查得怎么样」,缓存它们会让用户进页面就看到一条过期的错误提示。 + * + * 也刻意**不能**被当成「查证过的空」—— 快照里的空列表只说明上次没查到,不能推出 + * 「你从未提交」。空态标题只认这一轮的 fresh 结果(见 useMyIssues 的 hasFreshData)。 + */ +export interface MyIssuesSnapshot { + items: MyIssueItem[]; + githubEnhancement: { login: string; source: GithubEnhancementSource } | null; + /** ISO 写入时间。只用于诊断,**不做过期判断** —— 旧数据也比空白好,进页面一定会刷新。 */ + cachedAt: string; +} + /** * 查询失败时跨进程回传的**稳定脱敏码**。刻意不回原始 Error.message —— * 它可能带 userData 绝对路径或上游响应片段,细节只留在 main 日志里。 diff --git a/i18n/GLOSSARY.md b/i18n/GLOSSARY.md index 7f308858a58..30cb984dff9 100644 --- a/i18n/GLOSSARY.md +++ b/i18n/GLOSSARY.md @@ -96,7 +96,7 @@ - **Directory** — Directory 用「目录」(现状 96:4),Folder 用「文件夹」(37:20),两个英文词在中文里也分开。**本条刻意不设 forbidden**:英文侧自己就在一句话里混用二者("not a folder. Choose a project directory."、"re-select the working directory and wait for the folder badge"),而条件禁用是句子级匹配,区分不了句内哪个中文词对应哪个英文词——实测 4 处命中全是假阳性,那 4 条中文其实译得完全正确。要真正管住这对词,得先清理英文侧的 path / folder / directory 混用。此条目保留为文档指引,供人查阅。 - **Dismiss** — 中文用「关闭」(现状 6:4)。同一个 chat 模块里,errorBanner 写「关闭错误提示」、interruptedBanner 写「忽略此中断提示」。条件禁用:「忽略」是 Ignore 的正确译法、「知道了」是 Got it 的正确译法,只在英文源为 Dismiss 时才算违规。 - **Effort** — 模型的推理投入档位(low/medium/high/…)。用「推理强度」有三重依据:①同类工具 i18n 里断层第一(GitHub 代码搜索共现量级 1452,第二名「思考强度」431);②OpenAI 简中帮助中心即用此词;③跨 provider 中立——Cindy 的 create_worker 用一个 effort 同时映射 Codex reasoning effort 与 Claude thinking 预算,而 Anthropic 官方简中叫「努力程度」、OpenAI 叫「推理强度」,需要不偏向任一方的词。禁用项各有硬理由:「思考深度」「推理深度」会与国内产品普遍的「深度思考」开关撞车,且语义错误(低 effort 是想得少,不是想得浅);「努力程度」在中文里强烈指向人的绩效评价;「推理等级/级别」未入禁用但不推荐——Cindy 已有 model tier 概念,「等级」易被读成模型档位。 -- **Issue** — 保留英文(2026-07 裁决)。现状「工单」32 /「议题」4 是既有 bug 的来源:列表按钮写「新建议题」、点进去页面标题写「新建工单」。「问题」不入 forbidden——它在「修复问题和失败」这类泛指语境下是正常中文,不是 Issue 的误译。checkCase 关闭:issue 同时是常用英语单词,prompt 模板里的 "fix the issue" 是正常用法,做大小写约束会制造 9 处假阳性。(2026-07 修正:ko 原声明 Issue 仅 3 处,实测 이슈 45 处才是主流,改随本语言主流;ja 的 Issue 43:2 保持不变。)ja 的「課題」同样是多义词(问题 / 课题),按 whenEn=Issue 条件禁用——插件创建 prompt 里的 "define the problem" 用「課題」是正确的。豁免两个 Jira capability:那里指的是 Jira 自己的记录类型,日文 UI 的既定叫法是「課題」,保留英文反而与用户实际看到的 Jira 界面对不上。产品自身的 Issue 不受影响。 +- **Issue** — 保留英文(2026-07 裁决)。现状「工单」32 /「议题」4 是既有 bug 的来源:列表按钮写「新建议题」、点进去页面标题写「新建工单」。「问题」不入 forbidden——它在「修复问题和失败」这类泛指语境下是正常中文,不是 Issue 的误译。checkCase 关闭:issue 同时是常用英语单词,prompt 模板里的 "fix the issue" 是正常用法,做大小写约束会制造 9 处假阳性。(2026-07 修正:ko 原声明 Issue 仅 3 处,实测 이슈 45 处才是主流,改随本语言主流;ja 的 Issue 43:2 保持不变。)ja 的「課題」同样是多义词(问题 / 课题),按 whenEn=Issue 条件禁用——插件创建 prompt 里的 "define the problem" 用「課題」是正确的。豁免两个 Jira capability:那里指的是 Jira 自己的记录类型,日文 UI 的既定叫法是「課題」,保留英文反而与用户实际看到的 Jira 界面对不上。产品自身的 Issue 不受影响。 菜单入口(titleBar.menuItems.issues 与系统菜单 labels.issues)刻意不用术语:那里回答的是「点这里能干什么」,邻居全是动作短语(帮助 / 检查更新 / 最新更新介绍),夹一个英文名词既断风格,也让不熟悉 GitHub 的用户不知道该不该点 —— zh-CN 用「问题反馈」、ja 用「フィードバック」(ja 同级项同样全是日文,原先的 Issue 一样孤立)。这不是把 Issue 译成那些词:指代该类对象的位置(页面标题、正文、提示)仍写 Issue,因为点进去就跳 GitHub,名字必须对得上 —— 判据与 ja 的 Jira 豁免同源(跟用户实际看到的外部界面一致)。en 保持 Issues、ko 保持 이슈(各自在本语言里本就是自然的入口词)。 - 豁免范围:`desktop:scheduler.editor.script.capabilityDescs.jira_comment`、`desktop:scheduler.editor.script.capabilityDescs.jira_read` - **Plugin** — 中文一律「插件」,不保留英文原词(现状 94:4)。历史上 Ghost / Plugin 两种叫法并存,对外统一为「插件」。 - **Project** — 中文一律「项目」(现状 119:15)。同一个侧边栏里筛选叫「项目」、批量操作提示叫「Project」是既有 bug。小写 project 不入 forbidden——它常出现在路径与文件名里(project.json)。 diff --git a/i18n/glossary.json b/i18n/glossary.json index 7fc4d4ee860..ca4fbe337c7 100644 --- a/i18n/glossary.json +++ b/i18n/glossary.json @@ -537,7 +537,7 @@ } ] }, - "note": "保留英文(2026-07 裁决)。现状「工单」32 /「议题」4 是既有 bug 的来源:列表按钮写「新建议题」、点进去页面标题写「新建工单」。「问题」不入 forbidden——它在「修复问题和失败」这类泛指语境下是正常中文,不是 Issue 的误译。checkCase 关闭:issue 同时是常用英语单词,prompt 模板里的 \"fix the issue\" 是正常用法,做大小写约束会制造 9 处假阳性。(2026-07 修正:ko 原声明 Issue 仅 3 处,实测 이슈 45 处才是主流,改随本语言主流;ja 的 Issue 43:2 保持不变。)ja 的「課題」同样是多义词(问题 / 课题),按 whenEn=Issue 条件禁用——插件创建 prompt 里的 \"define the problem\" 用「課題」是正确的。豁免两个 Jira capability:那里指的是 Jira 自己的记录类型,日文 UI 的既定叫法是「課題」,保留英文反而与用户实际看到的 Jira 界面对不上。产品自身的 Issue 不受影响。", + "note": "保留英文(2026-07 裁决)。现状「工单」32 /「议题」4 是既有 bug 的来源:列表按钮写「新建议题」、点进去页面标题写「新建工单」。「问题」不入 forbidden——它在「修复问题和失败」这类泛指语境下是正常中文,不是 Issue 的误译。checkCase 关闭:issue 同时是常用英语单词,prompt 模板里的 \"fix the issue\" 是正常用法,做大小写约束会制造 9 处假阳性。(2026-07 修正:ko 原声明 Issue 仅 3 处,实测 이슈 45 处才是主流,改随本语言主流;ja 的 Issue 43:2 保持不变。)ja 的「課題」同样是多义词(问题 / 课题),按 whenEn=Issue 条件禁用——插件创建 prompt 里的 \"define the problem\" 用「課題」是正确的。豁免两个 Jira capability:那里指的是 Jira 自己的记录类型,日文 UI 的既定叫法是「課題」,保留英文反而与用户实际看到的 Jira 界面对不上。产品自身的 Issue 不受影响。 菜单入口(titleBar.menuItems.issues 与系统菜单 labels.issues)刻意不用术语:那里回答的是「点这里能干什么」,邻居全是动作短语(帮助 / 检查更新 / 最新更新介绍),夹一个英文名词既断风格,也让不熟悉 GitHub 的用户不知道该不该点 —— zh-CN 用「问题反馈」、ja 用「フィードバック」(ja 同级项同样全是日文,原先的 Issue 一样孤立)。这不是把 Issue 译成那些词:指代该类对象的位置(页面标题、正文、提示)仍写 Issue,因为点进去就跳 GitHub,名字必须对得上 —— 判据与 ja 的 Jira 豁免同源(跟用户实际看到的外部界面一致)。en 保持 Issues、ko 保持 이슈(各自在本语言里本就是自然的入口词)。", "minorityByDesign": { "ja": "ja 侧 Issue 43/63 本就是主流,此处无偏离;保留字段以说明与 ko 的 이슈 有意分道——各语言跟随本语言主流,不强求跨语言一致。" },