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 (