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 = {}): MyIssuesServiceDeps }; } +describe('isSnapshotWorthy', () => { + /** + * 判据矩阵直接钉一遍。两侧都会有人想「顺手收紧 / 顺手放宽」:把 absent 也算成丢内容, + * 会当场废掉全部用户的首屏快照(接口未上线是常态);把 unknown 当成正常放行, + * 缩水的列表就会覆盖完整快照。 + */ + const ok: ChannelHealth = { platform: 'ok', ledger: 'ok', enhancement: 'ok' }; + + it('三路都 ok ⇒ 配写', () => { + expect(isSnapshotWorthy(ok)).toBe(true); + }); + + it('absent 放行 —— 没配 / 那边压根没这份数据,不是丢内容', () => { + expect(isSnapshotWorthy({ ...ok, platform: 'absent' })).toBe(true); + expect(isSnapshotWorthy({ ...ok, enhancement: 'absent' })).toBe(true); + // 当前所有用户的真实形态:接口未上线 + 没配增强。这条要是 false,快照永远写不出来。 + expect(isSnapshotWorthy({ platform: 'absent', ledger: 'ok', enhancement: 'absent' })).toBe(true); + }); + + it('任一路 failed ⇒ 拒写(逐路都要拦,漏一路就是一次永久数据丢失)', () => { + for (const key of ['platform', 'ledger', 'enhancement'] as const) { + expect(isSnapshotWorthy({ ...ok, [key]: 'failed' }), key).toBe(false); + } + }); + + it('任一路 unknown ⇒ 拒写 —— 不确定时保守,别覆盖上一份完整快照', () => { + for (const key of ['platform', 'ledger', 'enhancement'] as const) { + expect(isSnapshotWorthy({ ...ok, [key]: 'unknown' }), key).toBe(false); + } + }); +}); + describe('mergeIssues', () => { it('账本 only 的条目状态标 unknown,标题用账本记的那一版', () => { const [item] = mergeIssues([ledgerRecord()], []); @@ -326,6 +361,466 @@ describe('MyIssuesService.list', () => { // 身份查到了就如实回传,只是这一次没并进内容。 expect(result.githubEnhancement).toEqual({ login: 'octocat', source: 'ghost' }); expect(result.items).toEqual([]); + // 没有兜底通道可用 ⇒ 这一路算「配了却没用上」,UI 要能据此说明。 + expect(result.githubEnhancementFailed).toBe(true); + }); + + /** + * 身份解析的约定:**返回 null = 没配(静默),失败一律抛出 = 配了却用不上(要提示)**。 + * 上一版 runtime 把 gh 身份查询的异常咽成 null,于是两者不可区分 —— token 过期 / + * 被撤销 / GitHub 限流时,用户直接在 GitHub 提的那些 issue 静静消失,页面一个字都不说, + * 而缩水的结果还会覆盖首屏快照。 + */ + it('身份解析抛错 = 配了却用不上:算 failed,且不许覆盖快照', async () => { + const writeSnapshot = vi.fn(); + const service = new MyIssuesService( + makeDeps({ + readLedger: () => [ledgerRecord()], + resolveGithubEnhancement: async () => { + throw new Error('HTTP 401 Bad credentials'); + }, + writeSnapshot, + }), + ); + + const result = await service.list(); + // 主列表照常出来 —— 这一路失败从不打挂整页。 + expect(result.items.map((i) => i.number)).toEqual([1001]); + expect(result.githubEnhancementFailed).toBe(true); + // 连来源都不知道,所以身份为 null;UI 据此选不提插件的那版提示。 + expect(result.githubEnhancement).toBeNull(); + expect(writeSnapshot).not.toHaveBeenCalled(); + }); + + it('身份解析返回 null = 没配:正常状态,不算 failed,照常写快照', async () => { + const writeSnapshot = vi.fn(); + const service = new MyIssuesService( + makeDeps({ + readLedger: () => [ledgerRecord()], + resolveGithubEnhancement: async () => null, + writeSnapshot, + }), + ); + + const result = await service.list(); + expect(result.githubEnhancementFailed).toBe(false); + expect(writeSnapshot).toHaveBeenCalledTimes(1); + }); + + 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('平台接口还没上线(platform-unavailable)→ 照常写快照,这是当前所有用户的常态', async () => { + const writeSnapshot = vi.fn(); + const service = new MyIssuesService( + makeDeps({ + readLedger: () => [ledgerRecord()], + fetchPlatformIssues: async () => ({ ok: false as const, reason: 'platform-unavailable' }), + writeSnapshot, + }), + ); + + await service.list(); + // 平台侧根本还没有这份数据可给 —— 账本 + 增强就是当下能拿到的全部,不算丢内容。 + // 把它当成「不配写」等于让快照永远写不出来,首屏加速整个失效。 + expect(writeSnapshot).toHaveBeenCalledTimes(1); + expect(writeSnapshot.mock.calls[0]![0].items.map((i: { number: number }) => i.number)).toEqual( + [1001], + ); + }); + + it('结果被截断 → 照常写快照(「还有更多」不等于「这些不对」)', async () => { + const writeSnapshot = vi.fn(); + const service = new MyIssuesService( + makeDeps({ + fetchPlatformIssues: async () => ({ + ok: true as const, + page: { + issues: Array.from({ length: SEARCH_PAGE_SIZE }, (_, i) => + remoteIssue({ number: i + 1 }), + ), + totalCount: SEARCH_PAGE_SIZE + 40, + }, + }), + writeSnapshot, + }), + ); + + await expect(service.list()).resolves.toMatchObject({ truncated: true }); + expect(writeSnapshot).toHaveBeenCalledTimes(1); + }); + + it('这一次丢了内容 → 不写快照,保留上一份完整的', async () => { + for (const [label, over] of [ + // 平台本该有却没拿到:网络 / 服务端异常。 + [ + 'fetch-failed', + { fetchPlatformIssues: async () => Promise.reject(new Error('ECONNRESET')) }, + ], + // 登录态不可用,平台那部分同样缺了。 + [ + 'not-signed-in', + { + fetchPlatformIssues: async () => ({ ok: false as const, reason: 'not-signed-in' as const }), + }, + ], + // 配了增强却没用上:少掉的正是用户直接在 GitHub 上提的那些。 + [ + 'githubEnhancementFailed', + { + resolveGithubEnhancement: async () => GHOST_VIEWER, + searchAuthoredIssues: async () => { + throw new Error('HTTP 422 Validation Failed'); + }, + }, + ], + ] as const) { + const writeSnapshot = vi.fn(); + const service = new MyIssuesService( + makeDeps({ readLedger: () => [ledgerRecord()], writeSnapshot, ...over }), + ); + + // 结果照常交付(降级不是错误),只是不许覆盖快照。 + await expect(service.list()).resolves.toMatchObject({ + items: [expect.objectContaining({ number: 1001 })], + }); + expect(writeSnapshot, label).not.toHaveBeenCalled(); + } + }); + + it('账本读取失败 → 不写快照(丢的是只有本机才有的那些记录)', async () => { + // readLedgerSafely 会把失败静默换成空数组 —— 那是刻意的(不能拖累另两路), + // 但**不能不记录**:否则「丢了全部本机记录」和「本来就没有记录」长得一模一样, + // 缩水的列表照样覆盖完整快照,用户下次冷启动就永久少掉平台还没上线时唯一的来源。 + const writeSnapshot = vi.fn(); + const service = new MyIssuesService( + makeDeps({ + readLedger: () => { + throw new Error('EACCES: permission denied'); + }, + fetchPlatformIssues: async () => ({ + ok: true as const, + page: { issues: [remoteIssue({ number: 7 })], totalCount: 1 }, + }), + writeSnapshot, + }), + ); + + // 主列表照常出(账本失败不拖累另两路)。 + await expect(service.list()).resolves.toMatchObject({ + items: [expect.objectContaining({ number: 7 })], + }); + expect(writeSnapshot).not.toHaveBeenCalled(); + }); + + it('身份解析还在飞就整体超时 → 不写快照,但也不提示(连配没配都不知道)', async () => { + // 这条钉住两个方向相反的结论来自同一个 unknown: + // - 快照侧保守拒写 —— 可能真丢了内容,不能覆盖上一份完整的; + // - 提示侧保守静默 —— 对没配增强的用户说「增强没用上」是在断言我们不知道的事。 + // 只标记「已 reject」的实现会把这里当成「没配」,两条都判错。 + const writeSnapshot = vi.fn(); + const service = new MyIssuesService( + makeDeps({ + enhancementTimeoutMs: 5, + readLedger: () => [ledgerRecord()], + resolveGithubEnhancement: () => new Promise(() => {}), + writeSnapshot, + }), + ); + + const result = await service.list(); + expect(result.items.map((i) => i.number)).toEqual([1001]); + expect(result.githubEnhancementFailed).toBe(false); + 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` 正常、搜本仓 + * 却被 GitHub 以 422 拒绝(未显式授权的仓库即使公开也搜不到)。上一版就此整路放弃, + * 而本机 gh CLI 明明有权限 —— 用户于是在页面上看到「还没有提交过 Issue」, + * 而他 GitHub 名下有 34 条。 + */ + it('主通道失败 → 换兜底通道,拿到的内容照常并入,且不算失败', async () => { + const searchAuthoredIssues = vi.fn(async () => { + throw new Error('HTTP 422 Validation Failed'); + }); + const searchAuthoredIssuesFallback = vi.fn(async () => ({ + issues: [remoteIssue({ number: 34 })], + totalCount: 1, + })); + const service = new MyIssuesService( + makeDeps({ + resolveGithubEnhancement: async () => GHOST_VIEWER, + searchAuthoredIssues, + searchAuthoredIssuesFallback, + }), + ); + + const result = await service.list(); + expect(searchAuthoredIssuesFallback).toHaveBeenCalledWith('octocat'); + expect(result.items.map((i) => i.number)).toEqual([34]); + expect(result.items[0]!.sources).toEqual(['github-account']); + // 回退成功 = 用户拿到了数据,没有可见损失,不该提示。 + expect(result.githubEnhancementFailed).toBe(false); + expect(result.degraded).toBeNull(); + }); + + it('兜底通道不可用(没装 / 没登录 gh)→ 标记失败,主列表照常', async () => { + const service = new MyIssuesService( + makeDeps({ + readLedger: () => [ledgerRecord()], + resolveGithubEnhancement: async () => GHOST_VIEWER, + searchAuthoredIssues: async () => { + throw new Error('HTTP 422'); + }, + searchAuthoredIssuesFallback: async () => null, + }), + ); + + const result = await service.list(); + expect(result.githubEnhancementFailed).toBe(true); + // 账本那一半照常出 —— 增强失败绝不拖累主列表。 + expect(result.items.map((i) => i.number)).toEqual([1001]); + expect(result.degraded).toBeNull(); + }); + + it('兜底通道自己也抛错 → 标记失败,不把整页打挂', async () => { + const service = new MyIssuesService( + makeDeps({ + resolveGithubEnhancement: async () => GHOST_VIEWER, + searchAuthoredIssues: async () => { + throw new Error('HTTP 422'); + }, + searchAuthoredIssuesFallback: async () => { + throw new Error('gh exploded'); + }, + }), + ); + + await expect(service.list()).resolves.toMatchObject({ + githubEnhancementFailed: true, + degraded: null, + items: [], + }); + }); + + it('gh-cli 主通道失败时不调兜底 —— 它自己就是兜底,没有下一条可换', async () => { + const searchAuthoredIssuesFallback = vi.fn(async () => ({ + issues: [remoteIssue({ number: 99 })], + totalCount: 1, + })); + const service = new MyIssuesService( + makeDeps({ + resolveGithubEnhancement: async () => ({ source: 'gh-cli', login: 'octocat', token: 't' }), + searchAuthoredIssues: async () => { + throw new Error('network down'); + }, + searchAuthoredIssuesFallback, + }), + ); + + const result = await service.list(); + expect(searchAuthoredIssuesFallback).not.toHaveBeenCalled(); + expect(result.githubEnhancementFailed).toBe(true); + }); + + it('剩余预算不足时不启动兜底 —— 那次请求注定被丢弃又取消不掉', async () => { + // withDeadline 只停止等待,GithubClient 不支持 AbortSignal;主通道耗掉大半预算 + // 才失败时启动兜底 = 白耗一次 GitHub 额度。宁可直接判失败,让 UI 如实说。 + const searchAuthoredIssuesFallback = vi.fn(async () => ({ + issues: [remoteIssue({ number: 34 })], + totalCount: 1, + })); + let clock = 0; + const service = new MyIssuesService( + makeDeps({ + now: () => clock, + enhancementTimeoutMs: 2_000, + resolveGithubEnhancement: async () => GHOST_VIEWER, + searchAuthoredIssues: async () => { + clock = 1_900; // 只剩 100ms,低于 MIN_FALLBACK_BUDGET_MS + throw new Error('HTTP 422'); + }, + searchAuthoredIssuesFallback, + }), + ); + + const result = await service.list(); + expect(searchAuthoredIssuesFallback).not.toHaveBeenCalled(); + expect(result.githubEnhancementFailed).toBe(true); + }); + + it('剩余预算充足时照常启动兜底', async () => { + const searchAuthoredIssuesFallback = vi.fn(async () => ({ + issues: [remoteIssue({ number: 34 })], + totalCount: 1, + })); + let clock = 0; + const service = new MyIssuesService( + makeDeps({ + now: () => clock, + enhancementTimeoutMs: 8_000, + resolveGithubEnhancement: async () => GHOST_VIEWER, + searchAuthoredIssues: async () => { + clock = 500; // 还剩 7.5s + throw new Error('HTTP 422'); + }, + searchAuthoredIssuesFallback, + }), + ); + + const result = await service.list(); + expect(searchAuthoredIssuesFallback).toHaveBeenCalledTimes(1); + expect(result.items.map((i) => i.number)).toEqual([34]); + expect(result.githubEnhancementFailed).toBe(false); + }); + + it('主通道成功时不碰兜底通道', async () => { + const searchAuthoredIssuesFallback = vi.fn(async () => null); + const service = new MyIssuesService( + makeDeps({ + resolveGithubEnhancement: async () => GHOST_VIEWER, + searchAuthoredIssues: async () => ({ issues: [remoteIssue()], totalCount: 1 }), + searchAuthoredIssuesFallback, + }), + ); + + const result = await service.list(); + expect(searchAuthoredIssuesFallback).not.toHaveBeenCalled(); + expect(result.githubEnhancementFailed).toBe(false); + }); + + it('没配增强时既不搜也不算失败 —— 没配是正常状态', async () => { + const searchAuthoredIssuesFallback = vi.fn(async () => null); + const service = new MyIssuesService( + makeDeps({ resolveGithubEnhancement: async () => null, searchAuthoredIssuesFallback }), + ); + + const result = await service.list(); + expect(searchAuthoredIssuesFallback).not.toHaveBeenCalled(); + expect(result.githubEnhancement).toBeNull(); + expect(result.githubEnhancementFailed).toBe(false); + }); + + it('兜底也算在同一次总 deadline 内,不给增强第二份预算', async () => { + // 两段各起计时器的写法会让页面最坏等两倍时长(#1103 review 里出现过)。 + const service = new MyIssuesService( + makeDeps({ + readLedger: () => [ledgerRecord()], + enhancementTimeoutMs: 40, + resolveGithubEnhancement: async () => GHOST_VIEWER, + searchAuthoredIssues: async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + throw new Error('HTTP 422'); + }, + // 主通道已用掉 30ms,兜底再要 60ms —— 合计必须被 40ms 的总预算切断。 + searchAuthoredIssuesFallback: async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + return { issues: [remoteIssue({ number: 34 })], totalCount: 1 }; + }, + }), + ); + + const result = await service.list(); + expect(result.items.map((i) => i.number)).toEqual([1001]); + expect(result.githubEnhancementFailed).toBe(true); + }); }); it('任一路远端总数多于返回条数时标 truncated', async () => { 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 19d807f48a8..da63ef76247 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'); @@ -58,8 +63,11 @@ const GHOST_IDENTITY_TIMEOUT_MS = 5_000; /** * 增强搜索的插件通道超时。service 层另有一道整体超时兜着,但这里也必须传 —— * 那道只是放弃等待,这道才真正让插件调用自己了结(通道默认 330s)。 + * + * 刻意不占满 service 那 8s 预算:插件搜不到时还要走 gh CLI 兜底,两段合计必须留在 + * 同一次总 deadline 内。权限类失败是立即 422,所以正常情况下兜底有近 8s 可用。 */ -const GHOST_SEARCH_TIMEOUT_MS = 6_000; +const GHOST_SEARCH_TIMEOUT_MS = 4_000; let serviceInstance: MyIssuesService | null = null; @@ -70,12 +78,19 @@ export function getMyIssuesService(): MyIssuesService { fetchPlatformIssues: fetchPlatformIssues, 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(); @@ -119,25 +134,50 @@ function mapPlatformFailure(err: unknown): MyIssuesDegradedReason { return 'fetch-failed'; } +/** + * 身份解析:插件优先,本机 gh CLI 兜底。 + * + * 这里锁定的只是**身份来源**,不代表数据也只能从那条通道取 —— 两者曾被混为一谈: + * 插件报出身份后 gh CLI 就再也不会被尝试,于是 PAT 搜不动本仓时整路放弃。取数的回退 + * 在 service 层(searchAuthoredIssuesFallback),与身份来源解耦。 + * + * **返回 null = 一条都没配(正常状态,静默);抛出 = 配了却问不出身份**(要提示,且不许拿 + * 缩水的结果覆盖首屏快照)。上一版把两条通道的身份失败都咽成 null,于是与「没配」不可 + * 区分:凭据过期 / 被撤销 / 通道超时 / GitHub 限流时,用户直接在 GitHub 提的那些 issue + * 静静消失,页面一个字都不说,而缩水的结果还照样覆盖了完整快照。 + * + * 判据是「**有没有通道配过**」,不是「哪一步报了错」—— + * `isCindyGithubGhostUsable` 含 `isGithubCredentialSaved()`,所以它为真就意味着用户确实 + * 存过 GitHub 凭据;「装了插件但从未授权」根本进不到这个分支(那时它为假)。同理 gh 那路 + * 以「有没有 token」判配没配。任一通道配过却一个身份都没拿到,就是配了用不上。 + * + * (曾经错在这里:以为插件那步含糊、怕对「装了没授权」的用户误报,于是让它静默落到 + * 「没配」。但那种用户压根到不了这一步 —— 前提判错,结论也就跟着错。) + */ async function resolveGithubEnhancement(): Promise { const ghostDeps = getSharedGithubUserSubmitterDeps(); // workdir 传 null:/issues 是全局页面,没有会话工作目录上下文。 - if (isCindyGithubGhostUsable(ghostDeps, null)) { + const ghostConfigured = isCindyGithubGhostUsable(ghostDeps, null); + if (ghostConfigured) { const login = await readGhostViewerLogin(ghostDeps); + // 插件问不出身份时不直接判死:gh CLI 可能有权限,下面照常再试一次。 if (login) return { source: 'ghost', login }; } const token = await getSharedGhCliTokenSource().readToken(); - if (!token) return null; - try { - const user = await userScopedClient(token).getCurrentUser(); - if (typeof user.login === 'string' && user.login.length > 0) { - return { source: 'gh-cli', login: user.login, token }; + if (!token) { + // gh 这一路没配。插件那一路要是配过,说明「配了却一个身份都没拿到」⇒ 失败。 + if (ghostConfigured) { + throw new Error('github enhancement identity lookup failed on every configured channel'); } - } catch (err) { - log.debug('gh cli viewer lookup failed', { error: errorText(err) }); + return null; + } + // 有 token 却问不出身份,同样是配了却用不上。 + const user = await userScopedClient(token).getCurrentUser(); + if (typeof user.login !== 'string' || user.login.length === 0) { + throw new Error('gh cli viewer lookup returned no login'); } - return null; + return { source: 'gh-cli', login: user.login, token }; } async function readGhostViewerLogin( @@ -159,16 +199,33 @@ async function readGhostViewerLogin( } } -async function searchAuthoredIssues( - viewer: GithubEnhancementViewer, - login: string, -): Promise { +/** + * 两条通道共用同一份查询参数 —— 各写一份迟早会漂移(而且 login 的校验漏在哪条上, + * 那条就能把 login 里的空格 / 冒号当查询限定符送出去)。 + */ +function authoredSearchParams(login: string): { + q: string; + sort: string; + order: 'desc'; + per_page: number; +} { if (!GITHUB_LOGIN_RE.test(login)) { throw new Error(`refusing to search with a malformed GitHub login: ${login}`); } const { owner, repo } = MY_ISSUES_REPOSITORY; - const q = `repo:${owner}/${repo} is:issue author:${login}`; - const params = { q, sort: 'created', order: 'desc' as const, per_page: SEARCH_PAGE_SIZE }; + return { + q: `repo:${owner}/${repo} is:issue author:${login}`, + sort: 'created', + order: 'desc', + per_page: SEARCH_PAGE_SIZE, + }; +} + +async function searchAuthoredIssues( + viewer: GithubEnhancementViewer, + login: string, +): Promise { + const params = authoredSearchParams(login); if (viewer.source === 'ghost') { const operation = await callCindyGithubOperation( @@ -184,6 +241,20 @@ async function searchAuthoredIssues( return parseIssuePage(await repoScopedClient(requireToken(viewer)).searchIssuesAndPRs(params)); } +/** + * 兜底通道:本机 `gh auth token`。插件 PAT 搜不动本仓时(fine-grained token 对未显式 + * 授权的仓库返回 422,即使仓库公开)由它接手 —— gh 的 OAuth token 权限完整。 + * + * 返回 null = 没装 / 没登录 gh,没有兜底可用。ghCliTokenSource 会探测 homebrew 等绝对 + * 路径,所以 GUI 启动的正式版(PATH 精简)同样能找到 gh。 + */ +async function searchAuthoredIssuesFallback(login: string): Promise { + const token = await getSharedGhCliTokenSource().readToken(); + if (!token) return null; + const page = await repoScopedClient(token).searchIssuesAndPRs(authoredSearchParams(login)); + return parseIssuePage(page); +} + function requireToken(viewer: GithubEnhancementViewer): string { if (!viewer.token) throw new Error('gh-cli viewer is missing its token'); return viewer.token; diff --git a/apps/desktop/src/main/github-issue/myIssuesService.ts b/apps/desktop/src/main/github-issue/myIssuesService.ts index 8e1c8c97ac8..110f699266d 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'; @@ -38,6 +39,12 @@ const DEFAULT_CACHE_TTL_MS = 60_000; */ const DEFAULT_ENHANCEMENT_TIMEOUT_MS = 8_000; +/** + * 启动兜底搜索所需的最低剩余预算。低于它就不去试 —— 那次请求注定等不到、又不能取消 + * (见 canTryFallback),白耗一次 GitHub 额度。gh CLI 搜索实测远快于此。 + */ +const MIN_FALLBACK_BUDGET_MS = 1_500; + /** * 平台通道的整体超时。比 runtime 侧给 serverApiFetch 的单次 fetch 上限更长, * 因为它要覆盖**整条调用链**:401 → authManager.refresh() → 重试。那次 refresh @@ -88,6 +95,22 @@ export interface MyIssuesServiceDeps { viewer: GithubEnhancementViewer, login: string, ) => Promise; + /** + * 主通道搜索失败时的**兜底通道**(本机 gh CLI)。返回 null = 没有兜底可用。 + * + * 为什么必须有:身份能报出来 ≠ 这一路能查到数据。插件 PAT 若是 fine-grained + * token,`get_current_user` 正常、搜本仓却被 GitHub 以 422 拒绝(未显式授权的仓库 + * 即使公开也搜不到)—— 上一版就此整路放弃,而本机 gh CLI 明明有权限。 + * + * 分开注入而不是让 searchAuthoredIssues 内部消化:runtime 是真实接线、不进单测, + * 「主通道失败必须换通道再试」这条不变量只有放这一层才钉得住。 + */ + searchAuthoredIssuesFallback?: (login: string) => Promise; + /** + * 落地成功后写首屏快照(下次进页面先渲染它,不用空等远端)。 + * 注入而非直接 import,是为了让本模块保持 electron-free、单测不碰磁盘。 + */ + writeSnapshot?: (snapshot: MyIssuesSnapshot) => void; /** * 当前账号作用域标识(data owner + session generation)。**这是安全边界**: * issue 列表含标题、编号与 GitHub 用户名,属于账号私有数据。服务是进程级单例, @@ -121,6 +144,61 @@ function staleAccountScopeError(): Error { ); } +/** + * 单条输入通道这一次的健康状况。 + * + * 四态而不是布尔,是因为**「没给出内容」有四种性质完全不同的原因**,而下游两个消费者 + * 对它们的处置方向相反(见 `isSnapshotWorthy` 与 `githubEnhancementFailed`): + * - `ok` —— 查了,拿到了(可能就是空的,那是真的空); + * - `absent` —— **没配 / 那边压根还没有这份数据**。正常状态,不是损失; + * - `failed` —— 本该有却这次没拿到。内容真的少了一块; + * - `unknown` —— 连「配没配」都没问出来(整体超时打断在半路)。既不能说它失败, + * 也不能当它正常。 + */ +export type ChannelState = 'ok' | 'absent' | 'failed' | 'unknown'; + +/** + * 三路输入各自的健康状况。 + * + * **为什么要有这个类型**:此前判据直接从 `MyIssuesResult` 推断「这次丢没丢内容」,而那份 + * 结果里的健康信息是**残缺的** —— 平台的挤在 `degraded`、增强的挤在 + * `githubEnhancementFailed`、**账本的根本没有位置**(读失败时被静默换成空数组)。信息不在 + * 输入里,判据就必然漏;本 PR 因此连续三轮被指出漏输入(先 degraded、再身份解析失败、 + * 再账本失败与身份超时),每次都是补一个特例而不是补上缺的那一维。 + * + * 现在三路都必须显式报状态,判据从这里推导。再加第四路输入时,这个类型会强迫调用方声明 + * 它的健康 —— 漏输入从「靠人记得」变成结构上不可能。 + */ +export interface ChannelHealth { + platform: ChannelState; + ledger: ChannelState; + enhancement: ChannelState; +} + +/** + * 这一次的结果**配不配写进首屏快照**。不配写时保留上一份,不覆盖也不清空。 + * + * 判据:**三路都没丢内容**(全部 `ok` 或 `absent`)。`failed` 与 `unknown` 都拦下 —— + * 快照要跨进程活到下一次冷启动,又刻意不带健康状况,拿一份缩水的结果覆盖它,用户下次 + * 进页面看到的就是残缺列表加零提示;仍然离线的话,那份完整列表就永久没了。所以这一侧 + * 遇到不确定必须保守拒写。 + * + * `absent` 放行是关键:`platform-unavailable`(服务端读接口还没上线)是当前**所有**用户的 + * 常态,把它当成丢内容,快照就永远写不出来、整个首屏加速当场失效。「没配增强」同理。 + * + * `truncated` 刻意**不**拦:它说的是「还有更多」,不是「显示的这些不对」。首屏本来只需要 + * 第一页,与 UI 当场展示的内容一致。 + * + * 与 renderer 侧的 `canTrustEmptyList` 刻意**不是同一个判据,别去合并**:那边问「能不能 + * 断言用户从未提交」,空列表 + 平台 `absent` 必须答否;这边问「这些内容能不能原样留给 + * 下次首屏」,同样的组合答是。一个管断言缺失,一个管展示已有 —— 方向相反。 + */ +export function isSnapshotWorthy(health: ChannelHealth): boolean { + return [health.platform, health.ledger, health.enhancement].every( + (state) => state === 'ok' || state === 'absent', + ); +} + interface CacheEntry { at: number; scope: string; @@ -171,7 +249,7 @@ export class MyIssuesService { return this.inFlight.promise; } const promise = this.load() - .then((result) => this.settle(result, scope, epochAtStart)) + .then(({ result, health }) => this.settle(result, health, scope, epochAtStart)) .finally(() => { // 只清自己那条,别把切号后新起的在途请求误清掉。 if (this.inFlight?.promise === promise) this.inFlight = null; @@ -190,16 +268,51 @@ export class MyIssuesService { * 所以照常**返回**(拒绝只会让刚提交完的用户看到一次假错误),但**不得落缓存** —— * 否则接下来 60s 都会命中这个不含新 issue 的旧快照。 */ - private settle(result: MyIssuesResult, scope: string, epochAtStart: number): MyIssuesResult { + private settle( + result: MyIssuesResult, + health: ChannelHealth, + scope: string, + epochAtStart: number, + ): MyIssuesResult { if (this.deps.readScope() !== scope) { throw staleAccountScopeError(); } if (this.cacheEpoch === epochAtStart) { this.cache = { at: this.now(), scope, result }; + // 落盘快照比内存缓存**多一条**门槛(isSnapshotWorthy):内存缓存 60s 后自然过期, + // 而快照要跨进程活到下一次冷启动,还刻意不带健康状况 —— 用一份缩水的结果覆盖它, + // 用户下次进页面看到的就是残缺列表加零提示。判据吃的是三路健康而不是 result: + // result 里没有账本那一路的位置,只看它必然漏(本 PR 已因此栽过三次)。 + if (isSnapshotWorthy(health)) { + this.persistSnapshot(result); + } else { + log.debug('skipped the my-issues snapshot write; this result lost content', health); + } } return result; } + /** + * 快照是 best-effort 的首屏加速:写失败只记日志,绝不能把一次成功的查询翻成失败。 + * 刻意只带 items 与身份 —— degraded / failed / truncated 是「这一次查得怎么样」, + * 缓存它们会让用户进页面就看到一条过期的错误提示。 + */ + 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 挡不住「早于本次提交发起、晚于本次提交完成」 @@ -210,7 +323,7 @@ export class MyIssuesService { this.cacheEpoch += 1; } - private async load(): Promise { + private async load(): Promise<{ result: MyIssuesResult; health: ChannelHealth }> { const ledger = this.readLedgerSafely(); // 两路互不阻塞:平台通道挂了不能连可选增强一起拖掉,反之亦然。 @@ -219,14 +332,26 @@ export class MyIssuesService { this.loadGithubEnhancement(), ]); - return { - items: mergeIssues(ledger, enhancement.issues, platform.issues), + const result: MyIssuesResult = { + items: mergeIssues(ledger.records, enhancement.issues, platform.issues), githubEnhancement: enhancement.viewer ? { login: enhancement.viewer.login, source: enhancement.viewer.source } : null, + // 只有确知失败才提示。`unknown`(整体超时打断在半路,连配没配都没问出来)保持 + // 静默 —— 对没配增强的用户说「增强没用上」是在断言我们并不知道的事。 + // 快照那一侧对 `unknown` 的处置正相反(拒写),两者由同一份 health 各自推导。 + githubEnhancementFailed: enhancement.state === 'failed', degraded: platform.degraded, truncated: platform.truncated || enhancement.truncated, }; + return { + result, + health: { + platform: platform.state, + ledger: ledger.state, + enhancement: enhancement.state, + }, + }; } /** @@ -239,15 +364,19 @@ export class MyIssuesService { * * 不计入 degraded:那三个 reason 讲的都是平台通道的状态。账本读不到时,平台正常 * 就能给出完整列表(没有可见损失),平台也失败则用户已经看到对应提示。 + * + * 但**必须报出状态**:读失败时静默换成空数组,会让「丢了全部本机记录」的结果看起来 + * 和「本来就没有记录」一模一样,于是那份缩水的列表照样覆盖掉完整的首屏快照 —— + * 用户下次冷启动就永久少掉了只有账本才有的那些 issue。不提示是一回事,不记录是另一回事。 */ - private readLedgerSafely(): SubmittedIssueRecord[] { + private readLedgerSafely(): { records: SubmittedIssueRecord[]; state: ChannelState } { try { - return this.deps.readLedger(); + return { records: this.deps.readLedger(), state: 'ok' }; } catch (err) { log.warn('reading the submitted-issue ledger failed; continuing without it', { error: err instanceof Error ? err.message : String(err), }); - return []; + return { records: [], state: 'failed' }; } } @@ -255,6 +384,7 @@ export class MyIssuesService { issues: RemoteIssue[]; degraded: MyIssuesDegradedReason | null; truncated: boolean; + state: ChannelState; }> { let outcome: PlatformIssuesOutcome; try { @@ -269,16 +399,24 @@ export class MyIssuesService { } catch (err) { // fetchPlatformIssues 约定不抛;超时或它真抛了都不能把整页打挂。 log.warn('platform issues fetch failed', { error: errorText(err) }); - return { issues: [], degraded: 'fetch-failed', truncated: false }; + return { issues: [], degraded: 'fetch-failed', truncated: false, state: 'failed' }; } if (!outcome.ok) { log.debug('platform issues unavailable', { reason: outcome.reason }); - return { issues: [], degraded: outcome.reason, truncated: false }; + return { + issues: [], + degraded: outcome.reason, + truncated: false, + // 接口还没上线 = 平台侧**压根没有这份数据可给**,不是丢内容(而且这是当前所有 + // 用户的常态,当成丢的话首屏快照永远写不出来)。未登录 / 网络异常则是本该有却没拿到。 + state: outcome.reason === 'platform-unavailable' ? 'absent' : 'failed', + }; } return { issues: outcome.page.issues, degraded: null, truncated: isTruncated(outcome.page), + state: 'ok', }; } @@ -291,35 +429,147 @@ export class MyIssuesService { * 整页遮住。超时、失败、没配置三种情况对用户是同一个结果 ——「这次没有增强」, * 主列表照常出。 * - * 注:runtime 侧另给插件调用传了各自的 timeoutMs(身份 5s / 搜索 6s),那是让**通道 - * 自己了结**,与这里的页面等待上限目的不同,不能互相替代。 + * 注:runtime 侧另给插件调用传了各自的 timeoutMs(身份 5s / 搜索 4s),那是让**通道 + * 自己了结**,与这里的页面等待上限目的不同,不能互相替代。搜索那档留 4s 而不是占满, + * 是为了给下面的兜底通道留出预算 —— 两段合计仍在这一次总 deadline 内。 */ private async loadGithubEnhancement(): Promise<{ viewer: GithubEnhancementViewer | null; issues: RemoteIssue[]; truncated: boolean; + state: ChannelState; }> { // 总超时触发时也要能回传已经解析成功的身份:header 照常显示并入了谁名下的 issue, // 只是这一次没并进内容。所以把它记在闭包外。 let resolved: GithubEnhancementViewer | null = null; + /** + * 身份解析**有没有了结**,以及了结成什么样。三态缺一不可: + * - `pending` —— 还在飞。总 deadline 先到时就停在这里,此时**连配没配都不知道**; + * - `none` —— 返回了 null = 没配(约定:失败一律抛出,返回 null 只表示没配); + * - `failed` —— 自己抛了 = 配了却问不出身份。 + * + * `pending` 曾经缺失:只标记「已 reject」的话,gh CLI 有 token 但 getCurrentUser + * 挂住超过总预算时,promise 还没 reject,于是「配了但超时」被当成「没配」—— + * 既不提示,缩水的结果还照样覆盖完整快照。 + */ + // 包在对象里而不是裸 let:赋值发生在回调内,TS 的控制流分析追不到,读的时候会把 + // 类型窄成初始值 'pending'。 + const resolution: { at: 'pending' | 'none' | 'resolved' | 'failed' } = { at: 'pending' }; + const budgetMs = this.deps.enhancementTimeoutMs ?? DEFAULT_ENHANCEMENT_TIMEOUT_MS; + const startedAt = this.now(); try { return await this.withDeadline(async () => { - resolved = await this.deps.resolveGithubEnhancement(); + resolved = await this.deps.resolveGithubEnhancement().then( + (viewer) => { + resolution.at = viewer ? 'resolved' : 'none'; + return viewer; + }, + (err: unknown) => { + resolution.at = 'failed'; + throw err; + }, + ); const viewer = resolved; - if (!viewer) return { viewer: null, issues: [], truncated: false }; + // 没配增强是**正常状态**,不是失败。 + if (!viewer) return { viewer: null, issues: [], truncated: false, state: 'absent' as const }; try { const page = await this.deps.searchAuthoredIssues(viewer, viewer.login); - return { viewer, issues: page.issues, truncated: isTruncated(page) }; + return { + viewer, + issues: page.issues, + truncated: isTruncated(page), + state: 'ok' as const, + }; } catch (err) { - // 搜索失败(非超时)不算列表降级 —— 主路径是平台通道。 - log.debug('github enhancement search failed', { error: errorText(err) }); - return { viewer, issues: [], truncated: false }; + // 提到 warn:身份能报出来却搜不到是异常,而这条路的失败对用户是静默的 —— + // 记 debug 等于线上不可诊断(排查这个 bug 时日志里就只有平台通道的 404)。 + // 文案按「会不会真的换通道」分两种,否则排障时会被误导:gh-cli 主通道 + // (或没注入 fallback)时 searchViaFallback 直接判失败,并不会真去试。 + const willRetry = this.canTryFallback(viewer, budgetMs, startedAt); + log.warn( + willRetry + ? 'github enhancement search failed; trying the fallback channel' + : 'github enhancement search failed; no fallback channel to try', + { source: viewer.source, error: errorText(err) }, + ); + if (!willRetry) { + return { viewer, issues: [], truncated: false, state: 'failed' as const }; + } + return await this.searchViaFallback(viewer); } - }, this.deps.enhancementTimeoutMs ?? DEFAULT_ENHANCEMENT_TIMEOUT_MS, 'enhancement'); + }, budgetMs, 'enhancement'); } catch (err) { - // 没有 GitHub 身份是正常状态;解析失败与总超时同样只是「这次没有增强」。 + // 没有 GitHub 身份是正常状态;这一路失败也从不打挂整页。 log.debug('github enhancement unavailable', { error: errorText(err) }); - return { viewer: resolved, issues: [], truncated: false }; + // 走到这里 = 整条路径被总 deadline 打断(或 withDeadline 自己抛)。按身份解析 + // 停在哪一步定性,三条都必须区分: + // - 身份已拿到 ⇒ 搜索连兜底一起超时,配了却没用上 ⇒ failed; + // - 身份解析自己抛了 ⇒ 配了却问不出身份 ⇒ failed; + // - 身份返回了 null ⇒ 确实没配 ⇒ absent(正常状态,静默); + // - 身份还在飞 ⇒ **连配没配都不知道** ⇒ unknown。不提示(不能对没配的人说 + // 「增强没用上」),但也不许覆盖快照(可能真丢了内容)。两个消费者方向相反, + // 正是它必须独立于 failed 存在的原因。 + const state: ChannelState = + resolution.at === 'resolved' || resolution.at === 'failed' + ? 'failed' + : resolution.at === 'none' + ? 'absent' + : 'unknown'; + return { viewer: resolved, issues: [], truncated: false, state }; + } + } + + /** + * 现在还值不值得去试兜底通道。 + * + * 两个条件: + * 1. 有兜底可换 —— `ghost` 主通道 + 注入了 fallback。`gh-cli` 自己就是兜底。 + * 2. **剩余预算够** —— `withDeadline` 只停止等待,不能取消底层请求(GithubClient + * 不支持 AbortSignal,给它加会动到 git-context 等其它调用方)。主通道耗掉大半 + * 预算才失败时启动兜底,等于发一次注定被丢弃、却照样消耗 GitHub 额度的请求。 + * 宁可直接判失败,让 UI 如实说这一路没用上。 + */ + private canTryFallback( + viewer: GithubEnhancementViewer, + budgetMs: number, + startedAt: number, + ): boolean { + if (!this.deps.searchAuthoredIssuesFallback || viewer.source !== 'ghost') return false; + // budgetMs <= 0 表示关掉了 deadline(仅测试用),此时不做预算判断。 + if (budgetMs <= 0) return true; + return budgetMs - (this.now() - startedAt) >= MIN_FALLBACK_BUDGET_MS; + } + + /** + * 换本机 gh CLI 再搜一次。**只在 canTryFallback() 为真时调用** —— 「有没有兜底可换」 + * 的判据只留在那一处,不在这里重复一份(两处判据迟早分歧,本页已栽过几次)。 + * + * 兜底通道自己说没有(没装 / 没登录 gh)或它也失败 ⇒ `failed: true`,让 UI 说明这一路 + * 配了却没用上;回退成功 ⇒ `failed: false`,用户已经拿到数据,没有可见损失就不提示。 + */ + private async searchViaFallback(viewer: GithubEnhancementViewer): Promise<{ + viewer: GithubEnhancementViewer; + issues: RemoteIssue[]; + truncated: boolean; + state: ChannelState; + }> { + const fallback = this.deps.searchAuthoredIssuesFallback; + if (!fallback) { + return { viewer, issues: [], truncated: false, state: 'failed' }; + } + try { + const page = await fallback(viewer.login); + if (!page) { + log.warn('no fallback channel available for the github enhancement'); + return { viewer, issues: [], truncated: false, state: 'failed' }; + } + log.info('github enhancement recovered through the fallback channel', { + count: page.issues.length, + }); + return { viewer, issues: page.issues, truncated: isTruncated(page), state: 'ok' }; + } catch (err) { + log.warn('github enhancement fallback search failed too', { error: errorText(err) }); + return { viewer, issues: [], truncated: false, state: 'failed' }; } } 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/__tests__/myIssuesIpc.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/myIssuesIpc.test.ts index d7ade739b3e..a4d4be82e7c 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/myIssuesIpc.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/myIssuesIpc.test.ts @@ -11,7 +11,13 @@ import type { MyIssuesResult } from '../../../shared/myIssues'; import { handleMyIssuesList } from '../my-issues'; function emptyResult(): MyIssuesResult { - return { items: [], githubEnhancement: null, degraded: null, truncated: false }; + return { + items: [], + githubEnhancement: null, + githubEnhancementFailed: false, + degraded: null, + truncated: false, + }; } describe('handleMyIssuesList', () => { @@ -61,6 +67,7 @@ describe('handleMyIssuesList', () => { error: 'unexpected', items: [], githubEnhancement: null, + githubEnhancementFailed: false, degraded: null, truncated: false, }); 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 dc37f6a831b..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'; @@ -27,10 +31,15 @@ export type MyIssuesListResponse = error: MyIssuesErrorCode; items: []; githubEnhancement: null; + githubEnhancementFailed: false; degraded: null; truncated: false; }; +export interface MyIssuesSnapshotDeps { + read: () => MyIssuesSnapshot | null; +} + export interface MyIssuesListDeps { list: (options: { force?: boolean }) => Promise; } @@ -62,13 +71,39 @@ export async function handleMyIssuesList( error: stale ? 'stale-account-scope' : 'unexpected', items: [], githubEnhancement: null, + githubEnhancementFailed: false, degraded: null, truncated: false, }; } } +/** + * 首屏快照读取。与 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 86bd5a45239..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)。 @@ -31,7 +34,7 @@ import { useRegisterCCAgentSidebar } from '@/features/cc-agent/useRegisterCCAgen import { MyIssueList } from './MyIssueList'; import { useMyIssues } from './hooks/useMyIssues'; -import { selectMyIssuesNotices } from './lib/myIssuesNotices'; +import { canTrustEmptyList, selectMyIssuesNotices } from './lib/myIssuesNotices'; import { prefillIssueCommandDraft } from './lib/startIssueChat'; const GITHUB_ISSUES_URL = 'https://github.com/makecindy/cindy/issues'; @@ -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() { ) : ( <> - + )} @@ -263,16 +266,37 @@ function SubmitHintBar({ onStartIssueChat }: { onStartIssueChat: () => void }) { * 一条都没有时的引导:怎么提、去哪看。正文与 CTA 沿用改版前的整页引导; * 标题换成空态该说的话 —— 原来那句「Issue 已迁移至 GitHub」是整页引导时代的说法, * 现在这一页本身就是能用的列表,再说「已迁移」会让人以为页面没做。 + * + * 标题分两版:只有三路查询都成功、确实为空时才敢说「还没有提交过 Issue」。任一路 + * 降级或失败时它就是一句错误断言 —— 用户在 GitHub 上有几十条 issue、只是这次没查到, + * 页面却告诉他从没提交过(实际发生过,见 canTrustEmptyList)。 */ -function EmptyGuide({ onStartIssueChat }: { onStartIssueChat: () => void }) { +function EmptyGuide({ + onStartIssueChat, + data, + hasFreshData, +}: { + onStartIssueChat: () => void; + data: MyIssuesResult | null; + hasFreshData: boolean; +}) { const { t } = useTranslation(); + // 这一轮还没查完(或数据来自落盘快照)时**整个标题不渲染**:引导正文与 CTA 无论有没有 + // issue 都成立,标题却是个结论。上一版在这里显示「暂时查不到你的 Issue」,于是有 35 条 + // 的用户先被告知查不到、再跳成列表 —— 比 loading 文案更糟,因为它是错的。 + // 标题从无到有是内容增加,不是形态替换。 + const trustable = hasFreshData && data ? canTrustEmptyList(data) : false; return (
-

{t('issueTracker.mine.emptyTitle')}

+ {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 4b6892174c5..2b8ea7dff97 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 @@ -51,13 +51,25 @@ function item(over: Partial = {}): MyIssueItem { }; } -function result(items: MyIssueItem[]): MyIssuesResult { - return { items, githubEnhancement: null, degraded: null, truncated: false }; +function result(items: MyIssueItem[], over: Partial = {}): MyIssuesResult { + return { + items, + githubEnhancement: null, + githubEnhancementFailed: false, + degraded: null, + truncated: false, + ...over, + }; } +/** + * 默认 hasFreshData=true("这一轮查过了"),因为多数用例断言的是查完之后的形态。 + * 首屏 / 快照顶上来的场景要显式传 false。 + */ function state(over: Record = {}) { return { data: null, + hasFreshData: true, loading: false, refreshing: false, error: null, @@ -115,26 +127,93 @@ 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.emptyTitle')).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.emptyTitle')).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.emptyTitle')).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('空列表:三路都真查过且成功才敢说「还没有提交过」', () => { + // githubEnhancement 必须非 null —— 没配增强时 GitHub 账号那一路根本没查过, + // 不能确证(判据见 canTrustEmptyList)。 + useMyIssuesMock.mockReturnValue( + state({ + data: result([], { githubEnhancement: { login: 'octocat', source: 'ghost' } }), + }), + ); + render(); + expect(screen.getByText('issueTracker.mine.emptyTitle')).toBeTruthy(); + }); + + it('空列表但没配增强:改说「暂时查不到」', () => { + useMyIssuesMock.mockReturnValue(state({ data: result([], { githubEnhancement: null }) })); + render(); + expect(screen.getByText('issueTracker.mine.emptyTitleUnverified')).toBeTruthy(); + expect(screen.queryByText('issueTracker.mine.emptyTitle')).toBeNull(); + }); + + it('空列表 + 任一路没查成:改说「暂时查不到」,不断言用户从未提交', () => { + // 用户在 GitHub 上有几十条 issue、只是这次没查到,页面却说「还没有提交过」—— + // 这正是本次要修的现场(平台接口未上线 + 插件 PAT 搜不动本仓)。 + for (const over of [ + { degraded: 'platform-unavailable' as const }, + { degraded: 'fetch-failed' as const }, + { githubEnhancementFailed: true }, + ]) { + useMyIssuesMock.mockReturnValue(state({ data: result([], over) })); + const view = render(); + expect(screen.getByText('issueTracker.mine.emptyTitleUnverified')).toBeTruthy(); + expect(screen.queryByText('issueTracker.mine.emptyTitle')).toBeNull(); + view.unmount(); + } + }); + it('一条都没有:显示空态引导,不显示常驻说明条(避免与引导里的说明重复)', () => { - useMyIssuesMock.mockReturnValue(state({ data: result([]) })); + useMyIssuesMock.mockReturnValue( + state({ + data: result([], { githubEnhancement: { login: 'octocat', source: 'ghost' } }), + }), + ); render(); expect(screen.getByText('issueTracker.mine.emptyTitle')).toBeTruthy(); diff --git a/apps/desktop/src/renderer/features/issue-tracker/__tests__/myIssuesNotices.test.ts b/apps/desktop/src/renderer/features/issue-tracker/__tests__/myIssuesNotices.test.ts index 3cea320b4b7..e18c900e4b8 100644 --- a/apps/desktop/src/renderer/features/issue-tracker/__tests__/myIssuesNotices.test.ts +++ b/apps/desktop/src/renderer/features/issue-tracker/__tests__/myIssuesNotices.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; import type { MyIssueItem, MyIssuesResult } from '@/../shared/myIssues'; -import { selectMyIssuesNotices } from '../lib/myIssuesNotices'; +import { canTrustEmptyList, selectMyIssuesNotices } from '../lib/myIssuesNotices'; function item(over: Partial = {}): MyIssueItem { return { @@ -27,12 +27,16 @@ function result(over: Partial = {}): MyIssuesResult { return { items: [], githubEnhancement: null, + githubEnhancementFailed: false, degraded: null, truncated: false, ...over, }; } +/** 配好且身份可用的插件通道增强 —— 多组用例共用。 */ +const enhanced = { login: 'octocat', source: 'ghost' as const }; + describe('selectMyIssuesNotices', () => { it('一切正常时不打扰用户', () => { expect(selectMyIssuesNotices(result({ items: [item()] }))).toEqual([]); @@ -119,6 +123,94 @@ describe('selectMyIssuesNotices', () => { expect(selectMyIssuesNotices(ledgerGithubUser)).toEqual(['issueTracker.mine.fetchFailedHint']); }); + it('增强配了却没用上:单独一条提示,排在主来源之后', () => { + // 显式给出 ghost 来源:提示分了版本,插件专属那条只在确知是插件通道时才给 + // (来源见「增强失败提示按来源分版」那组用例),所以这里不能靠 fixture 默认值。 + const ghostFailed = { githubEnhancementFailed: true, githubEnhancement: enhanced } as const; + expect(selectMyIssuesNotices(result(ghostFailed))).toEqual([ + 'issueTracker.mine.enhancementFailedHint', + ]); + // 两路各自出问题时两条都要说,顺序稳定(平台是主来源,排前面)。 + expect( + selectMyIssuesNotices(result({ degraded: 'platform-unavailable', ...ghostFailed })), + ).toEqual([ + 'issueTracker.mine.platformUnavailableHint', + 'issueTracker.mine.enhancementFailedHint', + ]); + }); + + it('回退救回来时不提示 —— 没有可见损失就不打扰用户', () => { + // service 在兜底通道拿到数据后会把 githubEnhancementFailed 置回 false。 + expect( + selectMyIssuesNotices( + result({ githubEnhancementFailed: false, items: [item({ state: 'open' })] }), + ), + ).toEqual([]); + }); + + describe('canTrustEmptyList', () => { + it('三路都真查过且成功才可确证「真的没有」', () => { + expect(canTrustEmptyList(result({ githubEnhancement: enhanced }))).toBe(true); + }); + + it('没配增强 ⇒ 不可确证 —— GitHub 账号那一路根本没查过', () => { + // githubEnhancementFailed 此时是 false(没配不是失败),只看它就会把「从未查过」 + // 当成「查过且为空」。平台侧不知道用户绕过 Cindy 直接在 GitHub 提的那些 issue, + // 只有增强查得到 —— 对那种用户会重演本次要修的错误断言。 + expect(canTrustEmptyList(result({ githubEnhancement: null }))).toBe(false); + }); + + it('任一路降级或失败就不可确证', () => { + for (const over of [ + { degraded: 'platform-unavailable' as const }, + { degraded: 'not-signed-in' as const }, + { degraded: 'fetch-failed' as const }, + { githubEnhancementFailed: true }, + ]) { + expect(canTrustEmptyList(result({ githubEnhancement: enhanced, ...over }))).toBe(false); + } + }); + }); + + describe('增强失败提示按来源分版', () => { + it('ghost 来源:给插件令牌那版指引', () => { + expect( + selectMyIssuesNotices( + result({ + githubEnhancementFailed: true, + githubEnhancement: { login: 'octocat', source: 'ghost' }, + }), + ), + ).toEqual(['issueTracker.mine.enhancementFailedHint']); + }); + + it('gh-cli 来源:用不提插件的通用版 —— 那种用户根本没在用插件', () => { + // searchViaFallback 对非 ghost 主通道直接判失败(它自己就是兜底),所以 + // githubEnhancementFailed 在 gh-cli 下同样为 true。给他「去插件页检查」 + // 等于指向不存在的页面,而 gh 用的是完整 OAuth token、失败多为网络或额度。 + expect( + selectMyIssuesNotices( + result({ + githubEnhancementFailed: true, + githubEnhancement: { login: 'octocat', source: 'gh-cli' }, + }), + ), + ).toEqual(['issueTracker.mine.enhancementFailedGenericHint']); + }); + + it('连来源都不知道(配了却问不出身份)→ 也用通用版,不指向插件页', () => { + // 新可达的组合:runtime 不再把 gh 身份查询的异常咽成 null,所以 + // githubEnhancement=null 且 failed=true 会真的出现(token 过期 / 撤销 / 限流)。 + // 判据写成「是不是 ghost」而不是「是不是 gh-cli」,未知情况才会落在保守那版 —— + // 反过来写会把这里误判成插件故障。 + expect( + selectMyIssuesNotices( + result({ githubEnhancementFailed: true, githubEnhancement: null }), + ), + ).toEqual(['issueTracker.mine.enhancementFailedGenericHint']); + }); + }); + it('截断与降级可以同时提示', () => { expect( selectMyIssuesNotices(result({ degraded: 'fetch-failed', truncated: true, items: [item()] })), 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 10ddef9ca88..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,9 +77,10 @@ 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, degraded: response.degraded, truncated: response.truncated, }); @@ -76,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; @@ -86,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/features/issue-tracker/lib/myIssuesNotices.ts b/apps/desktop/src/renderer/features/issue-tracker/lib/myIssuesNotices.ts index 95779eb9774..5c28e854ad9 100644 --- a/apps/desktop/src/renderer/features/issue-tracker/lib/myIssuesNotices.ts +++ b/apps/desktop/src/renderer/features/issue-tracker/lib/myIssuesNotices.ts @@ -40,6 +40,26 @@ function platformNoticeKey(data: MyIssuesResult, reason: 'unavailable' | 'fetchF return `issueTracker.mine.${base}${suffix}`; } +/** + * 列表是否**可被确证是完整的** —— 只有三路**都真查过且都成功**才算。 + * + * 空态标题靠它决定能不能说「还没有提交过 Issue」。三个条件各有必要: + * - `degraded === null` —— 平台通道成功; + * - `githubEnhancement !== null` —— GitHub 账号那一路**真的查过**。没配增强时它是 null, + * 而 `githubEnhancementFailed` 也会是 false(没配不是失败)—— 只看后者就会把「从未 + * 查过」当成「查过且为空」,对直接在 GitHub 上提过 issue 的用户重演本次要修的错误 + * 断言。平台侧不知道用户绕过 Cindy 直接提的那些,只有增强查得到; + * - `!githubEnhancementFailed` —— 那一路没有中途失败。 + * + * 代价是「还没有提交过 Issue」只在配了增强的用户身上出现,没配的用户看到的是 + * 「暂时查不到」。这是刻意的保守 —— 本页反复确立的原则:错误信息比保守措辞更糟。 + */ +export function canTrustEmptyList(data: MyIssuesResult): boolean { + return ( + data.degraded === null && data.githubEnhancement !== null && !data.githubEnhancementFailed + ); +} + export function selectMyIssuesNotices(data: MyIssuesResult): string[] { const notices: string[] = []; @@ -54,6 +74,28 @@ export function selectMyIssuesNotices(data: MyIssuesResult): string[] { if (data.degraded === 'fetch-failed') { notices.push(platformNoticeKey(data, 'fetchFailed')); } + // 可选增强配了却没用上:主来源的提示在前,这条补充说明「你 GitHub 那部分也没进来」。 + // 只在**兜底通道也没救回来**时出现 —— 回退成功就没有可见损失,不打扰用户。 + // + // 按来源分两版,**默认落在通用版**,只有确知是插件通道才给插件专属排障。 + // + // 三种 failed 现场,只有第一种能指向插件页: + // - source === 'ghost' —— 插件 PAT 搜不动本仓(实测 422),提示去插件页检查是对的; + // - source === 'gh-cli' —— 用户根本没在用插件,gh 用的是完整 OAuth token, + // 失败多为网络或额度,指向插件页等于指向一个不存在的页面; + // - githubEnhancement === null —— 配了却连身份都没问出来(token 过期 / 撤销 / 限流), + // 此时**连来源都不知道**,更不能断言是插件的问题。 + // + // 判据刻意写成「是不是 ghost」而不是「是不是 gh-cli」:未知情况必须落在保守那一版。 + // 反过来写(`=== 'gh-cli' ? 通用 : 插件`)会把上面第三种现场误判成插件故障 —— + // 与本页反复栽的同一个坑:default 分支断言了自己并不知道的事。 + if (data.githubEnhancementFailed) { + notices.push( + data.githubEnhancement?.source === 'ghost' + ? 'issueTracker.mine.enhancementFailedHint' + : 'issueTracker.mine.enhancementFailedGenericHint', + ); + } if (data.truncated) { notices.push('issueTracker.mine.truncatedHint'); } diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index b507670ed42..cd72eef3d89 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -6724,6 +6724,7 @@ "mine": { "refresh": "Refresh", "emptyTitle": "No issues submitted yet", + "emptyTitleUnverified": "Couldn't find your issues right now", "startIssueChat": "Start a new chat with /issue", "statusUnknown": "Status unknown", "sourceCindy": "Submitted from Cindy", @@ -6737,6 +6738,8 @@ "notSignedInHint": "Sign in to your Cindy account to see every issue you've filed, along with its current status.", "fetchFailedHint": "Couldn't load the latest status. Showing only submissions recorded on this device, so this list may be incomplete.", "fetchFailedPartialHint": "Couldn't load the latest status, so some issues may show incomplete status.", + "enhancementFailedHint": "Couldn't read the issues filed from your GitHub account. The Cindy GitHub plugin's access token may not have enough permissions — check it on the plugin page.", + "enhancementFailedGenericHint": "Couldn't read the issues filed from your GitHub account — likely a network hiccup or GitHub rate limit. Try refreshing in a moment.", "truncatedHint": "There are more issues than shown here — open GitHub for the full list." }, "redirect": { diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 2d4da38340e..0cc08e4d12e 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": { @@ -6714,6 +6714,7 @@ "mine": { "refresh": "更新", "emptyTitle": "送信した Issue はまだありません", + "emptyTitleUnverified": "現在 Issue を取得できません", "startIssueChat": "/issue で新しいチャットを開始", "statusUnknown": "ステータス不明", "sourceCindy": "Cindy から送信", @@ -6726,6 +6727,8 @@ "notSignedInHint": "Cindy アカウントにサインインすると、送信したすべての Issue と最新のステータスを表示できます。", "fetchFailedHint": "最新ステータスを取得できませんでした。この端末に記録された送信分のみを表示しているため、一覧が不完全な場合があります。", "fetchFailedPartialHint": "最新ステータスを取得できませんでした。一部の Issue はステータスが不完全な場合があります。", + "enhancementFailedHint": "あなたの GitHub アカウントで作成した Issue を読み取れませんでした。Cindy GitHub プラグインのアクセストークンの権限が不足している可能性があります。プラグインページで確認してください。", + "enhancementFailedGenericHint": "あなたの GitHub アカウントで作成した Issue を読み取れませんでした。ネットワークまたは GitHub のレート制限が原因の可能性があります。しばらくしてから再読み込みしてください。", "truncatedHint": "Issue が多いため一部のみ表示しています。全件は GitHub で確認してください。" }, "redirect": { diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index b369183f2b1..6cee792b96e 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -6714,6 +6714,7 @@ "mine": { "refresh": "새로 고침", "emptyTitle": "아직 제출한 이슈가 없습니다", + "emptyTitleUnverified": "현재 이슈를 가져올 수 없습니다", "startIssueChat": "/issue 로 새 채팅 시작", "statusUnknown": "상태 알 수 없음", "sourceCindy": "Cindy에서 제출", @@ -6726,6 +6727,8 @@ "notSignedInHint": "Cindy 계정에 로그인하면 제출한 모든 이슈와 최신 상태를 볼 수 있습니다.", "fetchFailedHint": "최신 상태를 가져오지 못했습니다. 이 기기에 기록된 제출 항목만 표시하므로 목록이 완전하지 않을 수 있습니다.", "fetchFailedPartialHint": "최신 상태를 가져오지 못했습니다. 일부 이슈의 상태가 완전하지 않을 수 있습니다.", + "enhancementFailedHint": "GitHub 계정으로 등록한 이슈를 읽지 못했습니다. Cindy GitHub 플러그인의 액세스 토큰 권한이 부족할 수 있습니다. 플러그인 페이지에서 확인해 주세요.", + "enhancementFailedGenericHint": "GitHub 계정으로 등록한 이슈를 읽지 못했습니다. 네트워크 문제이거나 GitHub 요청 제한일 수 있습니다. 잠시 후 다시 시도해 주세요.", "truncatedHint": "이슈가 많아 일부만 표시했습니다. 전체 목록은 GitHub에서 확인하세요." }, "redirect": { 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 4d19ab961b8..86be0778fe1 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": { @@ -6714,6 +6714,7 @@ "mine": { "refresh": "刷新", "emptyTitle": "还没有提交过 Issue", + "emptyTitleUnverified": "暂时查不到你的 Issue", "startIssueChat": "用 /issue 开始新对话", "statusUnknown": "状态未知", "sourceCindy": "由 Cindy 提交", @@ -6726,6 +6727,8 @@ "notSignedInHint": "登录 Cindy 账号后,可以看到你提交过的全部 Issue 和它们的最新状态。", "fetchFailedHint": "暂时取不到最新状态,当前只显示本机记录的提交,列表可能不完整。", "fetchFailedPartialHint": "暂时取不到最新状态,部分 Issue 的状态可能显示不全。", + "enhancementFailedHint": "暂时没能读取你 GitHub 账号名下的 Issue。可能是 Cindy GitHub 插件的访问令牌权限不足,可到插件页检查。", + "enhancementFailedGenericHint": "暂时没能读取你 GitHub 账号名下的 Issue,可能是网络问题或 GitHub 接口限流,稍后可以点右上角重试。", "truncatedHint": "Issue 较多,这里只列出了一部分,完整列表请在 GitHub 上查看。" }, "redirect": { diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 23ff23960d0..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) | { @@ -4473,6 +4481,7 @@ interface ElectronAPI { error: import('../shared/myIssues').MyIssuesErrorCode; items: []; githubEnhancement: null; + githubEnhancementFailed: false; degraded: null; truncated: false; } diff --git a/apps/desktop/src/shared/myIssues.ts b/apps/desktop/src/shared/myIssues.ts index 924f595c704..142cb1d6804 100644 --- a/apps/desktop/src/shared/myIssues.ts +++ b/apps/desktop/src/shared/myIssues.ts @@ -93,11 +93,39 @@ export interface MyIssuesResult { * UI 不得因此提示「你需要 GitHub 账号」。 */ githubEnhancement: { login: string; source: GithubEnhancementSource } | null; + /** + * 可选增强这一路**配置了却没能用上**(搜索被拒 / 超时,且兜底通道也没救回来)。 + * + * 与「没配」必须分开:没配是正常状态,配了用不上要让用户知道 —— 否则列表静静少掉 + * 一部分内容,列表为空时还会被说成「还没有提交过 Issue」。实测过的典型成因:插件 PAT + * 是 fine-grained token,能读身份但搜不了本仓(GitHub 对未显式授权的仓库返回 422, + * 即使仓库是公开的)。 + * + * 回退成功时为 false —— 用户已经拿到数据,没有可见损失就不打扰他。 + */ + githubEnhancementFailed: boolean; degraded: MyIssuesDegradedReason | null; /** true = 结果超出单页上限被截断,UI 必须明说而不是静默丢。 */ 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 的 이슈 有意分道——各语言跟随本语言主流,不强求跨语言一致。" },