diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ebbe73..737eb2b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Fixed +- #420:切换员工时岗位卡仍可显示上一位(#413 stale-while-revalidate),但裁撤 / 编辑只在 `card.data.id === selectedId` 时挂上。避免弹窗显示 A 的名字却对 B 发请求。 + - #413:切换员工时工作台不再先清空成骨架屏。岗位卡与对话线程保持上一位员工的内容直到新数据返回;空态节点在加载中不入 DOM,且 `.owb-turn-thread--empty[hidden]` 盖过 `display:flex`;组织树选中底不再 120ms 交叉淡入,避免两行同时发亮。切到尚无 session 的员工时,待空 session 确认后再清掉上一位的线程。 - #412:失败回合里错误块与「创建新回合重试」之间补回气泡内既有 10–16px 间距节奏(retry 行 `margin-top: 12px`);错误块多行行高与正文对齐,并使用对称内边距。纯视觉,不改重试行为。 diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx index 97aafc76..e7587b05 100644 --- a/apps/desktop/renderer/src/App.tsx +++ b/apps/desktop/renderer/src/App.tsx @@ -1560,8 +1560,11 @@ function AppInner({ const engineOk = health?.engine?.available === true; /** The frozen org-tree.v1 carries ids/budgets only; display names and modes - * arrive via the selected position card (/positions/:id). */ - const selectedPosition = card.data; + * arrive via the selected position card (`/positions/:id`). Stale-while-revalidate + * may keep the previous card on screen (#413) while `selectedId` is already the + * next employee. Mutating actions wait until the record matches, or the dismiss + * dialog names A and deletes B (#420). */ + const actionPosition = card.data?.id === selectedId ? card.data : null; const positions = useMemo(() => { if (!snapshot) return []; return flattenPositionIds(snapshot.tree).map((id) => ({ id, name: positionNames[id] ?? t("org.unknownPosition") })); @@ -2148,7 +2151,7 @@ function AppInner({ setMemorySource(source.kind === "mem_drive" ? "drive" : "docs"); setActiveModule("docs"); }} - actions={selectedPosition && selectedId ? ( + actions={actionPosition ? ( <> {/* Editing the record is available on every position, the company owner included: the owner is an employee with @@ -2160,15 +2163,15 @@ function AppInner({ - {selectedId !== snapshot?.owner ? ( - dismissPosition(selectedId)} /> + {actionPosition.id !== snapshot?.owner ? ( + dismissPosition(actionPosition.id)} /> ) : null} ) : undefined} diff --git a/apps/desktop/renderer/test/App.test.tsx b/apps/desktop/renderer/test/App.test.tsx index 76211ea9..6431feb4 100644 --- a/apps/desktop/renderer/test/App.test.tsx +++ b/apps/desktop/renderer/test/App.test.tsx @@ -1551,6 +1551,77 @@ it("#413 keeps employee A's card and thread while B's position fetch is in fligh expect(screen.queryByText("历史结果")).not.toBeInTheDocument(); }); +it("#420 does not dismiss B under A's name while B's position fetch is in flight", async () => { + const tree = { + ...snapshot, + positionCount: 2, + tree: [{ + ...snapshot.tree[0]!, + children: [{ id: "docs-writer", reportTo: "repo-owner", budget: snapshot.tree[0]!.budget, children: [] }], + }], + }; + const employeeB = { ...activeSession, positionId: "docs-writer", sessionId: "22222222-2222-4222-8222-222222222222" }; + let holdB = false; + let finishB!: (value: Awaited>) => void; + const orgApply = vi.fn().mockResolvedValue({ status: 200, body: { status: "applied" } }); + openedBridge({ + orgTree: vi.fn().mockResolvedValue({ status: 200, body: tree }), + orgApply, + position: vi.fn((id: string) => { + if (id === "docs-writer" && holdB) { + return new Promise>>((resolve) => { finishB = resolve; }); + } + return Promise.resolve({ status: 200, body: { position: { ...position, id, name: id === "repo-owner" ? "代码库负责人" : id }, agentEngine: "qoder" } }); + }), + sessions: vi.fn(async (id: string) => ({ + status: 200, + body: { + schemaVersion: "workbench-session-list.v1", + positionId: id, + activeSessionId: id === "docs-writer" ? employeeB.sessionId : activeSession.sessionId, + sessions: id === "docs-writer" ? [employeeB] : [activeSession], + }, + })), + sessionTurnHistory: vi.fn(async (sessionId: string) => ({ + status: 200, + body: history([apiTurn({ + conversationId: sessionId, + positionId: sessionId === employeeB.sessionId ? "docs-writer" : "repo-owner", + output: sessionId === employeeB.sessionId ? "B 的结果" : "历史结果", + })]), + })), + }); + render(); + const choose = async (id: string) => { + fireEvent.click((await screen.findByRole("tree")).querySelector(`[data-org-node-id="${id}"]`)!); + }; + const card = () => screen.getByRole("region", { name: "岗位档案" }); + await choose("repo-owner"); + expect(await screen.findByRole("heading", { name: "代码库负责人" })).toBeInTheDocument(); + expect(within(card()).getByRole("button", { name: "编辑" })).toBeInTheDocument(); + expect(within(card()).queryByRole("button", { name: "裁撤" })).toBeNull(); + + holdB = true; + await choose("docs-writer"); + expect(screen.getByRole("heading", { name: "代码库负责人" })).toBeInTheDocument(); + expect(within(card()).queryByRole("button", { name: "裁撤" })).toBeNull(); + expect(within(card()).queryByRole("button", { name: "编辑" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "确认裁撤 代码库负责人" })).toBeNull(); + + await act(async () => finishB({ + status: 200, + body: { position: { ...position, id: "docs-writer", name: "文档负责人" }, agentEngine: "qoder" }, + })); + expect(await within(card()).findByRole("heading", { name: "文档负责人" })).toBeInTheDocument(); + fireEvent.click(within(card()).getByRole("button", { name: "裁撤" })); + expect(screen.getByRole("heading", { name: "确认裁撤 文档负责人" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "确认裁撤并留痕" })); + await waitFor(() => expect(orgApply).toHaveBeenCalledWith({ + schemaVersion: "change-manifest.v1", + changes: [{ op: "delete", id: "docs-writer" }], + })); +}); + it("#413 clears employee A's thread once B is confirmed to have no session", async () => { const tree = { ...snapshot,