diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index 7fd01ab2..3e83b53a 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-11-12T07:21:29.752175+00:00", + "created_at": "2026-11-12T08:53:39.354182+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -403,13 +403,13 @@ }, { "path": "apps/web/src/api/researchProgress.test.tsx", - "sha256": "bca10743d61cc8298554cb0e1b31c4cbad572d6414f64d044156386a069f5471", - "bytes": 12050 + "sha256": "6e9d7175cede4c8951a1e1ae850b27591c27b3bee1614634b2158a4e13f1112b", + "bytes": 13450 }, { "path": "apps/web/src/api/researchProgress.ts", - "sha256": "07913207b788ac153ee03698353ad67ecd5376fc04ec4d05a0269776dc792dd0", - "bytes": 14935 + "sha256": "554a96c5bbbef465ac74d51a96b35ffd5684054727c20a363679d984dc133181", + "bytes": 14943 }, { "path": "apps/web/src/api/researchProgressCharacterization.test.ts", @@ -633,8 +633,8 @@ }, { "path": "apps/web/src/components/AnswerReadability.test.tsx", - "sha256": "ed2dca727566fdd426e43b77717702aa20b6a9037fd2660e76cb33679eeb044a", - "bytes": 5420 + "sha256": "d13f3aefac9ce16e5d2b22f7ff67c599dd4806006b9290850843a25830fa8431", + "bytes": 7996 }, { "path": "apps/web/src/components/ApprovalCard.test.tsx", @@ -723,8 +723,8 @@ }, { "path": "apps/web/src/components/ConfirmDialog.tsx", - "sha256": "921f58af49921abd3578db081d930086a4a3bde61bdf70aafc06d08cf0a78aae", - "bytes": 1524 + "sha256": "a067f479569fae667b62abe0338a1854c4368f063c3e37e2efbdc133f0ad61c4", + "bytes": 1592 }, { "path": "apps/web/src/components/ConfirmedFacts.test.tsx", @@ -753,13 +753,18 @@ }, { "path": "apps/web/src/components/ConversationEarlierRound.test.tsx", - "sha256": "4aca3030e5519fb03d2fad5b7ab7cedf50f64ce6b6cc2061bc788b687bc7d1e4", - "bytes": 4633 + "sha256": "03c9fc9d85345b51721aaf68481f616ee7b4227e4de4a14f4c958467229b1a71", + "bytes": 4994 + }, + { + "path": "apps/web/src/components/ConversationRunningTurn.test.tsx", + "sha256": "695fc6abf91ecfdc7f4f0a39645db3e41d2ac9ccb5377948500a4a9857b4a83d", + "bytes": 6581 }, { "path": "apps/web/src/components/ConversationTimeline.tsx", - "sha256": "dc4a30fa2bced151ee36dc7098c48c3dd790d1fee8c5477d727ea625874d0296", - "bytes": 13850 + "sha256": "8136396921dc0b61bf737ad57460c4b897326ebc069d9b24543d0c4937f3fa4c", + "bytes": 14619 }, { "path": "apps/web/src/components/ConversationTimelineAutoRetry.test.tsx", @@ -788,8 +793,8 @@ }, { "path": "apps/web/src/components/ConversationTurnView.tsx", - "sha256": "f29c60bab8e576c5afe5b7aca38f4a9ff83a14542999fd0a7e856ae0d5be47d8", - "bytes": 2814 + "sha256": "408c1fb7c86f0afc1348ef5898287a4862a989d2127fae5bb07f43a9d91cc9a9", + "bytes": 3793 }, { "path": "apps/web/src/components/Disclosure.tsx", @@ -843,13 +848,13 @@ }, { "path": "apps/web/src/components/FileConnectionPanel.test.tsx", - "sha256": "ffaab549da3e7faaecbd7cefcc63a6c9aa6c4fcc75f1a7a654ea6dc5231a99c4", - "bytes": 15559 + "sha256": "31939fe160bf28062c677dadced11aca7bbe164024bc179fe76bc5f63312dfda", + "bytes": 16126 }, { "path": "apps/web/src/components/FileConnectionPanel.tsx", - "sha256": "f3293ac86d597a6612a008625dfd220b85d736c5dd08a1a67e3c3eff8fea8eb0", - "bytes": 10777 + "sha256": "3aa25b36baf537cb883b1ba7f078639a1ffca8ee2e2287d21a992e13289a24f7", + "bytes": 10943 }, { "path": "apps/web/src/components/FileConnectionPanelParserError.test.tsx", @@ -1128,8 +1133,8 @@ }, { "path": "apps/web/src/components/ProjectPermanentDelete.tsx", - "sha256": "dad0d8d4b909785f77fc974aa4d2cba32f5a0efdf232d55663a9daca09a9cbf4", - "bytes": 3027 + "sha256": "a676887bae1283d3350ef0d1fb66bcca55582d609d467a0b5b80f12979d3c57b", + "bytes": 3095 }, { "path": "apps/web/src/components/ProjectReadinessCard.tsx", @@ -1173,8 +1178,8 @@ }, { "path": "apps/web/src/components/RenameDialog.tsx", - "sha256": "c101fe4768191f97bcf525e4843084f7c91a13b1055b6203170766197bfb30f7", - "bytes": 2113 + "sha256": "fa06451e1faf5d610428cd71a56367730c231ef4e94336adaca2ea1aa60c43a2", + "bytes": 2181 }, { "path": "apps/web/src/components/RequestLanguageFlow.test.tsx", @@ -1223,8 +1228,8 @@ }, { "path": "apps/web/src/components/ResearchLiveProgress.tsx", - "sha256": "a0637762b7b36c88c602846bbc446900c6f7be2980f2d8c98709a5f0307457db", - "bytes": 3761 + "sha256": "8e4e5ba3c13972410180e5d1d7540d33dea5705d6d8427974aae0cc542c9a51a", + "bytes": 4342 }, { "path": "apps/web/src/components/ResearchProgress.tsx", @@ -1468,8 +1473,8 @@ }, { "path": "apps/web/src/components/UsageSummary.test.tsx", - "sha256": "8d13a355f510dfaeaf3a1f3aec1a0d14feab9f361a16103b01c1a602d48f73e7", - "bytes": 1113 + "sha256": "dd3ea89f53d26e7e52490e3410066acf093f809381218941be91a628077c0ccc", + "bytes": 1299 }, { "path": "apps/web/src/components/WorkspaceContent.test.tsx", @@ -1603,8 +1608,8 @@ }, { "path": "apps/web/src/components/followupItems.ts", - "sha256": "e8261d5219c98ead93f52993483a4c0fa71588b1e7ca15d89dbfb2139a8ea8f1", - "bytes": 5124 + "sha256": "26290f1174395273ae71ab833ef7cd3c96040a1c01cc6f44691684ec91d1c77d", + "bytes": 6466 }, { "path": "apps/web/src/components/groundingTestFixture.ts", @@ -1678,8 +1683,8 @@ }, { "path": "apps/web/src/components/history/RestorePreviewDialog.tsx", - "sha256": "a88c19ee9bddded5e25eb53dfeaaaaa24f733b4e0f3ee7ef4a5ba842f3d2445f", - "bytes": 4506 + "sha256": "c520934ebf4ed8b268f5662198f510da2c32fac3ef8caf078145b3356a6d7057", + "bytes": 4575 }, { "path": "apps/web/src/components/history/RevisionComparison.tsx", @@ -1811,6 +1816,16 @@ "sha256": "35260383dd0b21e5c6bc582d122ad2380b5604d0b8d181d6c01e7a16ecce1f85", "bytes": 3401 }, + { + "path": "apps/web/src/components/returnFocus.test.tsx", + "sha256": "fa034e65795ffb8e43728989b5849ec1307f4ca1650d6f0ff68ee48055476092", + "bytes": 3951 + }, + { + "path": "apps/web/src/components/returnFocus.ts", + "sha256": "4269aa688a214a2954a60fbe4cbb19be5cbb1a33e37538be13f4e0f437bc1f86", + "bytes": 1644 + }, { "path": "apps/web/src/components/sameHypothesisText.ts", "sha256": "4b989096704ad8dae5ceee7cfac9b4cdd8545b4b3fb46a8156ba6ebb2d192e9a", @@ -2228,8 +2243,8 @@ }, { "path": "apps/web/src/i18n/ja/sourceMessagesJa.ts", - "sha256": "cce728a983fc6743a63d3f5bcaa944901261ef5748218854b7e454c8d11fa007", - "bytes": 7478 + "sha256": "ef05e9d8e225824aec9d8a9b397d380a5b017093219722f4118d90646921ec02", + "bytes": 7799 }, { "path": "apps/web/src/i18n/ja/traceMessagesJa.ts", @@ -2313,28 +2328,28 @@ }, { "path": "apps/web/src/i18n/progressEn.ts", - "sha256": "82737292fc24294094f0ba49f10ca1c34d21b4b0bb6523dfc247528d45854a95", - "bytes": 12022 + "sha256": "f6183e7f60a10335d711edf8bbfcd9a5db72c6ce6cb296725ac404d9ce31e5ee", + "bytes": 12151 }, { "path": "apps/web/src/i18n/progressJa.ts", - "sha256": "609d1656ddf9579773747adaa53f12792964acdb9e88b689a88dd9590b85de84", - "bytes": 13664 + "sha256": "e57d1e3e6e33c1229409b178a67c073ce5891185ee582be48f64e52f9086ed8e", + "bytes": 13793 }, { "path": "apps/web/src/i18n/progressKo.ts", - "sha256": "2ff891be8d4ac960c238abf5db48a57320a48b6e89f96e57c95a2e70b30b9d0f", - "bytes": 18395 + "sha256": "8dcbd48948a0813822baca9b11c2e1e0f206fd5caa86c2370a8eaab373f489db", + "bytes": 18403 }, { "path": "apps/web/src/i18n/progressZh.ts", - "sha256": "833952be9e61634fe160834ddbc262f23c4a28f95617737ebde87a734a210bff", - "bytes": 11923 + "sha256": "4eef14688f7e6507f6469c64ba3ae8d097931875cf6885ada66987487f3a1a53", + "bytes": 12052 }, { "path": "apps/web/src/i18n/progressZhHant.ts", - "sha256": "a8631dd525ce2e7bede65ce3f7efd09a701dc686d58740bdc26076d2c6222abb", - "bytes": 11941 + "sha256": "edbd514c5953b4e01d47c161cbc2362b980fe151fef881aae0b9555d8e581a4a", + "bytes": 12070 }, { "path": "apps/web/src/i18n/recordDialogsMessages.ts", @@ -2378,8 +2393,8 @@ }, { "path": "apps/web/src/i18n/sourceMessages.ts", - "sha256": "8e69990033fe21b6cab89fd29b058f62a00972c4738b21ca7e476044b24587a0", - "bytes": 11850 + "sha256": "251bc0fa24c14252d2c96f0aaadd839700045cca77d630ffacfbbefb32bebfee", + "bytes": 12378 }, { "path": "apps/web/src/i18n/stateFormatMessages.ts", @@ -2453,8 +2468,8 @@ }, { "path": "apps/web/src/i18n/uiFormatMessages.ts", - "sha256": "68e5eee7a6fc9d71a13800502ad034f31a3b635788778db63c6d92959d052b74", - "bytes": 18598 + "sha256": "fb025d04f9dcd2f7db9a9acd3fa823dfb7e813e2a9fbafa6fd300e39df9f2329", + "bytes": 18661 }, { "path": "apps/web/src/i18n/withColumns.ts", @@ -2463,8 +2478,8 @@ }, { "path": "apps/web/src/i18n/wordingRules.test.ts", - "sha256": "c1123ae5a3befebc886159ec32dc0037012a106fd190dee2c1ca9a7d6cc6e3e2", - "bytes": 13078 + "sha256": "870be8773cfe36cb09ec48b50271e6d870fb9f642083406dd8f308dccc3b4126", + "bytes": 13932 }, { "path": "apps/web/src/i18n/workspaceDetailMessages.ts", @@ -2593,8 +2608,8 @@ }, { "path": "apps/web/src/i18n/zh/sourceMessagesZh.ts", - "sha256": "b60b448c8dcebcc60fb8ed8923ca91e8ff99bb6a32bcb39778df181f2e91c5ba", - "bytes": 5323 + "sha256": "763ce2d5a40e5efadc88985e2602ae20af83067303a78f3e262709bd7f6cdc0f", + "bytes": 5542 }, { "path": "apps/web/src/i18n/zh/traceMessagesZh.ts", @@ -2728,8 +2743,8 @@ }, { "path": "apps/web/src/i18n/zhHant/sourceMessagesZhHant.ts", - "sha256": "308f999549c2cb9eda5ee0f550e98612ee2f76403f061c7386fa54a4b8eab6e1", - "bytes": 5336 + "sha256": "da02551fc3378c285d15859908bb770fce45d234e876c02ff336a76991df6637", + "bytes": 5555 }, { "path": "apps/web/src/i18n/zhHant/traceMessagesZhHant.ts", @@ -2758,8 +2773,8 @@ }, { "path": "apps/web/src/main.tsx", - "sha256": "6f59adbfa6ce1c5c7b965d66c51c44bdcfb11c1bbb97d48150025d8ee8987b4a", - "bytes": 673 + "sha256": "10dc3ada8337bf4f44c924aed165335f78d2942e38acccd586f129e1c40ab63f", + "bytes": 742 }, { "path": "apps/web/src/narrowLayoutRules.test.ts", diff --git a/apps/web/src/api/researchProgress.test.tsx b/apps/web/src/api/researchProgress.test.tsx index d8491d9f..f76a467d 100644 --- a/apps/web/src/api/researchProgress.test.tsx +++ b/apps/web/src/api/researchProgress.test.tsx @@ -208,6 +208,33 @@ describe("live research progress", () => { expect(html).not.toContain("EVIDENCE_FOCUS"); }); + // Seen with a real model on a one-line HTML source: every range carries the place "1행", and the screen said + // "1행, 1행, 1행 8곳" and "1행 · 1행 외 6곳", and repeated the last sentence of the reason as the state. + it("names a place once when several ranges share its label, and does not say the reason's sentence again as the state", () => { + const status = running({ + attempt: { + operation_id: "operation:o", + phase: "EVIDENCE_FOCUS", + status: "RUNNING", + draft_progress: { evidence_focus: { locators: [{ page: 1 }, { page: 1 }, { page: 1 }, { page: 2 }], total: 4 } }, + }, + }); + const live = liveResearchProgress(status)!; + // the count is still of ranges + expect(live.evidenceLabels).toEqual(["p.1", "p.1", "p.1", "p.2"]); + expect(live.missing).toContain("4곳"); + expect(live.missing).not.toMatch(/p\.1.*p\.1/); + expect(live.current.title).not.toMatch(/p\.1.*p\.1/); + const html = renderToStaticMarkup(); + expect(html).toContain("p.1 · p.2"); + expect(html).not.toContain("p.1 · p.1"); + // two places are named and two more ranges are counted + expect(html).toMatch(/p\.1 · p\.2[^<]*2/); + expect(live.current.detail).toContain(live.missing!); + expect(html.split(live.missing!).length - 1).toBe(1); + expect(html).not.toContain("판단 상태"); + }); + it("names source shortlist as choosing local excerpts, not a web search", () => { const live = liveResearchProgress( running({ diff --git a/apps/web/src/api/researchProgress.ts b/apps/web/src/api/researchProgress.ts index e1c0eba1..14986a6b 100644 --- a/apps/web/src/api/researchProgress.ts +++ b/apps/web/src/api/researchProgress.ts @@ -145,7 +145,7 @@ function workFacts(status: ResearchStatus, sourceUris: string[] = []): WorkFacts return { focus: extractFocus(status), constraints: extractConstraints(status), - locators: evidenceLabels(status).slice(0, 2), + locators: unique(evidenceLabels(status)).slice(0, 2), hypotheses, pendingHypotheses: unique(pending), decidedHypotheses: unique(decided), diff --git a/apps/web/src/components/AnswerReadability.test.tsx b/apps/web/src/components/AnswerReadability.test.tsx index 75bd259a..9e3aca65 100644 --- a/apps/web/src/components/AnswerReadability.test.tsx +++ b/apps/web/src/components/AnswerReadability.test.tsx @@ -1,6 +1,8 @@ import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { setLocale } from "../i18n/locale"; import { ResearchResultCard } from "./ResearchResultCard"; +import { splitItems } from "./followupItems"; import { criterionText, plainReason, splitReadable } from "./plainWording"; const digest = "a".repeat(64); @@ -84,3 +86,40 @@ describe("the answer screen", () => { for (const english of ["SYN-TC-RAIN", REASONS[0]]) expect(html, english).toContain(english); // kept, but folded }); }); + +// A real run records what is still open as "Resolve " and the requirement's question: a built-in criterion in the +// program's English, or the question the person asked. Fake-model results never carried these lines. +describe("what the program records as still open", () => { + afterEach(() => setLocale("ko")); + const ASKED = "LAB-42의 A 조건이 목표에 미달한 이유는 무엇인가?"; + const RECORDED = ["Resolve " + ASKED, "Resolve Discriminating predictions and counterevidence", + "Resolve Applicable test profile before causal promotion", "Resolve LAB-42", "Resolve detection_rate"]; + + it("names an open built-in criterion by its title and the open question as it was asked, without the program's English verb", () => { + const { plain, sentences, codes, technical } = splitItems(RECORDED, "ko"); + // a question is the person's own words: it is shown even when it has no Korean in it ("LAB-42") + expect(plain).toEqual([ASKED, "반대 근거", "적용할 시험", "LAB-42"]); + expect(sentences).toEqual([]); + // a bare field name of a criterion is a code and is only counted + expect(codes).toEqual(["Resolve detection_rate"]); + // every recorded line is still there for the folded technical part, as the program wrote it + expect([...technical].sort()).toEqual([...RECORDED].sort()); + }); + + it("does so for a result written in another language too, where English used to be shown as if the model had written it", () => { + const asked = "請只根據LAB-42的實驗記錄,說明A條件沒有達到目標的事實。"; + const { plain } = splitItems(["Resolve " + asked, RECORDED[1], RECORDED[2]], "zh-Hant"); + expect(plain).toEqual([asked, "반대 근거", "적용할 시험"]); + setLocale("ja"); + expect(splitItems([RECORDED[1]], "ja").plain).toEqual(["反証の根拠"]); + setLocale("en"); + expect(splitItems([RECORDED[1], RECORDED[2]], "en").plain).toEqual(["Counterevidence", "Test to apply"].map((_, index) => splitItems([RECORDED[index + 1]], "en").plain[0])); + for (const line of splitItems(RECORDED, "en").plain) expect(line).not.toMatch(/^Resolve /); + }); + + it("keeps the recorded lines in the card's folded technical block and nowhere else", () => { + const html = render({ answer: "a", answer_status: "PARTIAL_HOLD" }, { progressSummary: progress(RECORDED) }); + expect(visible(html)).not.toContain("Resolve"); + for (const line of RECORDED) expect(html, line).toContain(line); + }); +}); diff --git a/apps/web/src/components/ConfirmDialog.tsx b/apps/web/src/components/ConfirmDialog.tsx index 6377a3dc..6d4eb1de 100644 --- a/apps/web/src/components/ConfirmDialog.tsx +++ b/apps/web/src/components/ConfirmDialog.tsx @@ -1,6 +1,7 @@ import { Button, Callout, Classes, Dialog, type Intent } from "@blueprintjs/core"; import type { ReactNode } from "react"; import { t, useTranslation } from "../i18n/locale"; +import { useReturnFocus } from "./returnFocus"; /** * The one confirmation window of the app (archive, stop, disconnect): what will happen, in the screen @@ -19,6 +20,7 @@ export function ConfirmDialog({ title, children, confirmLabel, intent = "primary onClose: () => void; }) { useTranslation(); + useReturnFocus(); const close = () => { if (!pending) onClose(); }; diff --git a/apps/web/src/components/ConversationEarlierRound.test.tsx b/apps/web/src/components/ConversationEarlierRound.test.tsx index c4671ae9..9f495cc8 100644 --- a/apps/web/src/components/ConversationEarlierRound.test.tsx +++ b/apps/web/src/components/ConversationEarlierRound.test.tsx @@ -29,6 +29,7 @@ const status = { user_progress_summary: { schema_version: "1.0.0", request_revis coverage_matrix: null, next_user_action: null } as unknown as ResearchStatus; const EARLIER = "이 회차의 답변 점검 항목은 여기서 볼 수 없습니다. 최신 회차의 답에서만 보입니다."; const NOT_YET = "답변 점검 항목 정보를 아직 읽지 못했습니다"; +const NO_USAGE = "이 결과의 사용량 기록 없음"; afterEach(async () => { if (root) await act(async () => root!.unmount()); @@ -65,12 +66,17 @@ it("tells an earlier round that its answer checks are not shown here, and the la expect(first).not.toContain(NOT_YET); expect(latest).toContain(NOT_YET); expect(latest).not.toContain(EARLIER); + // The usage of an earlier round is not sent either. Its card does not claim that none was recorded; the latest + // round, whose usage would be sent if there were any, still says so. + expect(first).not.toContain(NO_USAGE); + expect(latest).toContain(NO_USAGE); }); it("does not call a round earlier when nothing says which request is the latest", async () => { await mount(undefined); for (const card of cards()) { expect(card).toContain(NOT_YET); + expect(card).toContain(NO_USAGE); expect(card).not.toContain(EARLIER); } }); diff --git a/apps/web/src/components/ConversationRunningTurn.test.tsx b/apps/web/src/components/ConversationRunningTurn.test.tsx new file mode 100644 index 00000000..1fa29433 --- /dev/null +++ b/apps/web/src/components/ConversationRunningTurn.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import type { ResearchStatus } from "../api/research"; +import { rpc } from "../api/rpcClient"; +import { ConversationTimeline } from "./ConversationTimeline"; + +// While a question is being researched its turn is already in the saved list, with no result yet. It used to be +// drawn as a folded line saying "no answer" with an "ask again" button, above the live progress of that very +// question (seen with a real model; a fake model finishes before the screen can show it). +vi.mock("../api/rpcClient", async importOriginal => ({ + ...await importOriginal(), + rpc: vi.fn(), +})); + +let root: Root | undefined; +let container: HTMLDivElement; +let client: QueryClient; +const digest = (letter: string) => letter.repeat(64); +const turn = (epoch: number, letter: string, text: string) => ({ request_epoch: epoch, request_revision_digest: digest(letter), operation_id: "operation:" + epoch, + text, edit_kind: "NEW", created_at: "2026-11-12T00:0" + epoch + ":00Z", authored_text_ref: { revision_digest: digest(letter) } }); +const turns = [turn(1, "a", "첫 질문"), turn(2, "b", "둘째 질문")]; +const status = (state: string, operation: string) => ({ operation_state: state, request: { operation_id: operation, authored_text: "둘째 질문" } }) as unknown as ResearchStatus; + +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; client?.clear(); container?.remove(); vi.resetAllMocks(); +}); + +async function mount(liveStatus: ResearchStatus | undefined, secondState = "RUNNING") { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.mocked(rpc).mockImplementation(async (method, args) => { + if (method === "thread/activity/list") return { operation_id: "", state: "SUCCEEDED", value: { conversation: { turns, next_before_epoch: null, history_limited: false } } }; + if (method === "operation/result/read") { + const first = args.operation_id === "operation:1"; + return { operation_id: "", state: "SUCCEEDED", value: { operation_id: args.operation_id, state: first ? "SUCCEEDED" : secondState, + result: first ? { thread_id: "t", request_epoch: 1, contract_version: 2, answer: "첫 답", answer_status: "ANSWERED" } : null } }; + } + throw new Error("not served: " + method); + }); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + await show(liveStatus); + for (let index = 0; index < 40 && container.querySelectorAll(".conversation-turn").length < 2; index += 1) await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); +} +const show = (liveStatus: ResearchStatus | undefined) => act(async () => root!.render( + undefined} onRetry={() => undefined}/>)); +const second = () => container.querySelector('.conversation-turn[data-turn-index="2"]')!; + +it("draws the turn being researched as its question only, with no 'no answer' line and no ask-again button", async () => { + for (const state of ["RUNNING", "PENDING"]) { + await mount(status(state, "operation:2")); + expect(second().querySelector(".user-message")?.textContent).toContain("둘째 질문"); + // nothing but the question: no folded line, and no empty result card either (a research about to start has none) + expect(second().hasAttribute("data-turn-running"), state).toBe(true); + expect(second().querySelector(".assistant-message"), state).toBeNull(); + expect(second().querySelector(".turn-summary")).toBeNull(); + expect(second().textContent).not.toContain("답 없음"); + expect([...container.querySelectorAll("button")].map(button => button.textContent)).not.toContain("같은 질문으로 다시 조사"); + // the earlier turn keeps its folded line + expect(container.querySelector('.conversation-turn[data-turn-index="1"] .turn-summary')?.textContent).toContain("1회차"); + await act(async () => root!.unmount()); root = undefined; client.clear(); container.remove(); + } +}); + +it("opens the answer when its research ends: the turn that was only a question becomes the open result card", async () => { + await mount(status("RUNNING", "operation:2")); + expect(second().querySelector(".assistant-message")).toBeNull(); + // the same screen, a moment later: the status now carries the result of that request + const finished = { operation_state: "SUCCEEDED", request: { operation_id: "operation:2", authored_text: "둘째 질문" }, + current_result: { operation_id: "operation:2", request_ref: { revision_digest: digest("b") }, terminal_reason: null, + result: { thread_id: "t", request_epoch: 2, contract_version: 2, answer: "둘째 답입니다", answer_status: "ANSWERED" } } } as unknown as ResearchStatus; + await show(finished); + expect(second().hasAttribute("data-turn-running")).toBe(false); + expect(second().querySelector(".assistant-message .answer-text")?.textContent).toContain("둘째 답입니다"); + // not behind a folded line with a preview and an "expand" button + expect(second().querySelector(".turn-summary")).toBeNull(); + // the earlier turn was folded and stays folded; nothing folds or opens on its own besides the new answer + expect(container.querySelector('.conversation-turn[data-turn-index="1"] .assistant-message')).toBeNull(); +}); + +it("keeps the line and the ask-again button for a turn that ended without an answer while another question runs", async () => { + await mount(status("RUNNING", "operation:9"), "FAILED"); + expect(second().querySelector(".turn-summary")).not.toBeNull(); + expect([...second().querySelectorAll("button")].map(button => button.textContent)).toContain("같은 질문으로 다시 조사"); +}); + +it("draws a turn whose research is over as its result card, whatever the old live status said", async () => { + for (const live of [status("FAILED", "operation:2"), undefined]) { + await mount(live, "FAILED"); + expect(second().hasAttribute("data-turn-running")).toBe(false); + expect(second().querySelector(".assistant-message")).not.toBeNull(); + await act(async () => root!.unmount()); root = undefined; client.clear(); container.remove(); + } +}); diff --git a/apps/web/src/components/ConversationTimeline.tsx b/apps/web/src/components/ConversationTimeline.tsx index 96c50074..375d5730 100644 --- a/apps/web/src/components/ConversationTimeline.tsx +++ b/apps/web/src/components/ConversationTimeline.tsx @@ -56,6 +56,10 @@ function isEarlierRound(liveStatus: ResearchStatus | undefined, requestDigest: s const latest = liveStatus?.user_progress_summary?.request_revision_digest; return Boolean(latest) && latest !== requestDigest; } +/** The saved turn whose research the live status says is running (or about to run) now. */ +function isRunningTurn(liveStatus: ResearchStatus | undefined, operationId: string) { + return ["PENDING", "RUNNING"].includes(liveStatus?.operation_state ?? "") && liveStatus?.request?.operation_id === operationId; +} /** The request the live status is about, when it has produced or kept a checkpoint; otherwise the live block shows progress only. */ function currentRequestOf(liveStatus: ResearchStatus | undefined) { @@ -112,10 +116,15 @@ function SavedTurnCard({ turn, projectId, threadId, status, liveStatus, savedBef onDetail: TimelineProps["onDetail"]; onRetry?: TimelineProps["onRetry"]; onOpenTrace?: TimelineProps["onOpenTrace"]; linked: ReturnType; }) { const requestDigest = turn.input.request_revision_digest; - return onRetry(turn.input.text) : undefined} usageLine={resultUsageLine(liveStatus, turn.input.operation_id)} + const earlier = isEarlierRound(liveStatus, requestDigest); + // The server sends the usage of the latest result and of the request now running only. An earlier round has + // none here, which is not the same as "no usage recorded": its line is left out (the conversation's total is + // under the question box). + const usageLine = earlier && !liveStatus?.result_usage?.[turn.input.operation_id] ? undefined : resultUsageLine(liveStatus, turn.input.operation_id); + return onRetry(turn.input.text) : undefined} usageLine={usageLine} hasSavedResult={savedBefore.has(requestDigest) || Boolean(liveStatus?.request?.operation_id === turn.input.operation_id && liveStatus.previous_result)} currentness={turn.currentness} historySelection={{ kind: "result", scope: { projectId, threadId, requestDigest }, operationId: turn.input.operation_id, - resultDigest: currentResultDigest(status, { projectId, threadId, requestDigest }, turn.input.operation_id) }} {...followupFor(liveStatus, requestDigest)} earlierRound={isEarlierRound(liveStatus, requestDigest)}/>; + resultDigest: currentResultDigest(status, { projectId, threadId, requestDigest }, turn.input.operation_id) }} {...followupFor(liveStatus, requestDigest)} earlierRound={earlier}/>; } export function ConversationTimeline(props: TimelineProps) { @@ -160,7 +169,8 @@ export function ConversationTimeline(props: TimelineProps) { {query.hasNextPage && } {query.error && {query.data ? t("timeline.refreshFailed") : t("timeline.loadFailedSpace")}} {query.data?.pages.some(page => page.limited) && {t("timeline.limited")}} - {turns.map((turn, index) => onRetry(turn.input.text) : undefined}> + {turns.map((turn, index) => onRetry(turn.input.text) : undefined} + inProgress={isRunningTurn(liveStatus, turn.input.operation_id)}> )} {unavailableHistory && {t("timeline.unsupported")}} diff --git a/apps/web/src/components/ConversationTurnView.tsx b/apps/web/src/components/ConversationTurnView.tsx index dbc4451c..0a8575cc 100644 --- a/apps/web/src/components/ConversationTurnView.tsx +++ b/apps/web/src/components/ConversationTurnView.tsx @@ -14,18 +14,34 @@ export function QuestionBubble({ heading, time, text }: { heading: string; time? * One past turn of the conversation. Folded by default so the newest turn stays in view: the question, then one line * with the turn number, its state tags (read from the same facts as the result card) and a preview of the answer. * "Expand" opens the full result card in place; a cut-off turn keeps its "ask again" button on the folded line. + * The turn that is being researched right now is its question only: the live block under it says what is happening, + * and a line saying "no answer" with an "ask again" button would be wrong while the answer is still being made. */ -export function ConversationTurnView({ turn, index, expanded: expandedAtFirst, onRetry, children }: { +export function ConversationTurnView({ turn, index, expanded: expandedAtFirst, onRetry, inProgress, children }: { turn: ConversationTurn; index: number; /** The newest turn starts open; earlier turns start folded. */ expanded: boolean; onRetry?: () => void; + /** This turn's research is running now. */ + inProgress?: boolean; /** The full result card for this turn. */ children: ReactNode; }) { const [open, setOpen] = useState(expandedAtFirst); + // A turn becomes the newest finished one when its research ends. It was folded (or question only) until then, + // and the answer the person waited for must not stay behind a one-line preview. Nothing is folded on its own. + const [wasNewest, setWasNewest] = useState(expandedAtFirst); + if (expandedAtFirst !== wasNewest) { + setWasNewest(expandedAtFirst); + if (expandedAtFirst) setOpen(true); + } const summary = turnSummary(turn); const label = message("turnIndex", index); + if (inProgress) { + return
+ +
; + } return
{open ? <> diff --git a/apps/web/src/components/FileConnectionPanel.test.tsx b/apps/web/src/components/FileConnectionPanel.test.tsx index 661ba56c..31a2a074 100644 --- a/apps/web/src/components/FileConnectionPanel.test.tsx +++ b/apps/web/src/components/FileConnectionPanel.test.tsx @@ -164,6 +164,9 @@ it("lets the scope be changed under the fold, and sends the changed one", async await act(async () => { button("Usage scope: Shared within this project")!.click(); }); expect([...scopeSelect()!.options].map(option => option.value)).toEqual(["PROJECT_SHARED", "EXPLICIT_GRANT"]); expect(container.textContent).toContain("lets every task in this project use the source as evidence"); + // No screen gives a task its permission and research reads the source either way: the help says so. + expect(container.textContent).toContain("there is no screen yet for giving a task permission"); + expect(container.textContent).toContain("research in this project can read the source whichever you choose"); await choose(scopeSelect()!, "EXPLICIT_GRANT"); expect(button("Usage scope: Explicitly permitted tasks only")).toBeDefined(); await start(["A.md"]); @@ -176,6 +179,8 @@ it("still asks for the scope in the hosted review environment", async () => { await select(["A.md"]); expect(scopeSelect()!.value).toBe(""); expect(button(t("composer.attach"))!.disabled).toBe(true); + // The choice is asked for here, so what the two choices do is said here too, without a fold. + expect([...container.querySelectorAll("p")].filter(p => p.textContent === t("sources.scopeHelp") && !p.closest(".source-scope-advanced"))).toHaveLength(1); await choose(scopeSelect()!, "PROJECT_SHARED"); expect(button(t("composer.attach"))!.disabled).toBe(false); }); diff --git a/apps/web/src/components/FileConnectionPanel.tsx b/apps/web/src/components/FileConnectionPanel.tsx index c124bffd..0b568e7e 100644 --- a/apps/web/src/components/FileConnectionPanel.tsx +++ b/apps/web/src/components/FileConnectionPanel.tsx @@ -106,7 +106,8 @@ export function FileConnectionPanel({ projectId, hosted = false }: { projectId: {hosted && } {canCancel && }
- {!hosted && ( + {/* The hosted review environment asks for the scope in the row above; what the choices do is said in both. */} + {hosted ?

{t("sources.scopeHelp")}

: (

{t("sources.scopeHelp")}

diff --git a/apps/web/src/components/ProjectPermanentDelete.tsx b/apps/web/src/components/ProjectPermanentDelete.tsx index ac177563..38dae886 100644 --- a/apps/web/src/components/ProjectPermanentDelete.tsx +++ b/apps/web/src/components/ProjectPermanentDelete.tsx @@ -6,6 +6,7 @@ import { rpc } from "../api/rpcClient"; import { message } from "../i18n/formatMessages"; import { t, useTranslation } from "../i18n/locale"; import type { ProjectSummary } from "../types"; +import { useReturnFocus } from "./returnFocus"; /** * Confirms and sends project/permanentDelete for one archived project. The name must be typed @@ -18,6 +19,7 @@ export function ProjectPermanentDelete({ target, onClose, onDeleted }: { onDeleted: (projectId: string) => void; }) { useTranslation(); + useReturnFocus(); const client = useQueryClient(); const [name, setName] = useState(""); const [leftover, setLeftover] = useState(false); diff --git a/apps/web/src/components/RenameDialog.tsx b/apps/web/src/components/RenameDialog.tsx index c6324101..af9e9c47 100644 --- a/apps/web/src/components/RenameDialog.tsx +++ b/apps/web/src/components/RenameDialog.tsx @@ -1,6 +1,7 @@ import { Button, Callout, Classes, Dialog, InputGroup } from "@blueprintjs/core"; import { useState, type ReactNode } from "react"; import { t, useTranslation } from "../i18n/locale"; +import { useReturnFocus } from "./returnFocus"; /** The longest name the server stores for a project or a conversation. */ const NAME_LIMIT = 500; @@ -16,6 +17,7 @@ export function RenameDialog({ title, current, notice, pending = false, error, o onClose: () => void; }) { useTranslation(); + useReturnFocus(); const [name, setName] = useState(current); const trimmed = name.trim(); const canSave = trimmed !== "" && trimmed !== current.trim() && !pending; diff --git a/apps/web/src/components/ResearchLiveProgress.tsx b/apps/web/src/components/ResearchLiveProgress.tsx index 3b8a2aa4..6bba61a3 100644 --- a/apps/web/src/components/ResearchLiveProgress.tsx +++ b/apps/web/src/components/ResearchLiveProgress.tsx @@ -7,6 +7,31 @@ import type { ResearchStatus } from "../api/research"; import { Disclosure } from "./Disclosure"; import { ResearchActivityStrip } from "./ResearchActivityStrip"; +type LiveProgress = NonNullable>; + +function evidenceScopeLine(progress: LiveProgress): string | null { + const shown = progress.evidenceLabels.length; + // Several ranges of one source can carry the same place label ("1행"); a place is named once. + const places = [...new Set(progress.evidenceLabels)]; + const coverage = progress.evidenceTotal != null && progress.evidenceTotal > shown + ? formatMessage("progressLocationCoverage", shown, progress.evidenceTotal) : null; + if (shown > 0) { + const named = places.slice(0, 2); + const remaining = shown > named.length ? formatMessage("moreLocations", shown - named.length) : ""; + return [named.join(" · ") + remaining, coverage].filter(Boolean).join(" · "); + } + if (progress.evidenceTotal === 0) return t("liveProgress.noCandidates"); + return coverage ? `${formatMessage("progressLocationUnavailable")} · ${coverage}` : null; +} + +function judgementLine(progress: LiveProgress): string | null | undefined { + const state = progress.hypothesisCount==null ? progress.missing + : formatMessage("hypothesesReviewed", progress.hypothesisCount, progress.reviewCount??0); + // The reason line often ends with the very sentence the state line would say; it is said once. + const judgement = state && progress.current.detail.includes(state) ? null : state; + return judgement; +} + export function ResearchLiveProgress({ status, sourceUris = [], @@ -20,21 +45,8 @@ export function ResearchLiveProgress({ } const notes = progress.events.filter(event => event.kind === "note" && !event.live).map(event=>event.text); const contextNotes = [...new Set([notes[0],notes.at(-1)])].filter((note):note is string=>Boolean(note)&¬e!==progress.current.title); - const shown = progress.evidenceLabels.length; - const coverage = progress.evidenceTotal != null && progress.evidenceTotal > shown - ? formatMessage("progressLocationCoverage", shown, progress.evidenceTotal) : null; - let evidenceScope: string | null = null; - if (shown > 0) { - const locations = progress.evidenceLabels.slice(0, 2).join(" · "); - const remaining = shown > 2 ? formatMessage("moreLocations", shown - 2) : ""; - evidenceScope = [locations + remaining, coverage].filter(Boolean).join(" · "); - } else if (progress.evidenceTotal === 0) { - evidenceScope = t("liveProgress.noCandidates"); - } else if (coverage) { - evidenceScope = `${formatMessage("progressLocationUnavailable")} · ${coverage}`; - } - const judgement = progress.hypothesisCount==null ? progress.missing - : formatMessage("hypothesesReviewed", progress.hypothesisCount, progress.reviewCount??0); + const evidenceScope = evidenceScopeLine(progress); + const judgement = judgementLine(progress); return (