Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 135 additions & 120 deletions SOURCE_MANIFEST.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion apps/web/src/api/resultEvidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,13 @@ function bindAnswer(selection: ResultSelection, displayed: Record<string, unknow
// it may only hold keys the result has. An older server sends none: the screen is then compared with the manifest itself.
const shownBody = value.shown_result ? answerContent(value.shown_result, thread!, epoch, terminal) : manifestBody;
if (value.shown_result && !Object.keys(shownBody).every(key => Object.hasOwn(resultBody, key))) mismatch();
// The latest round is shown from the summary view. A saved earlier round is shown from the stored result itself
// (operation/result/read returns it whole). What the screen shows must be, all of it, a part of one of these forms
// of the same stored answer; keys taken from two forms that disagree are refused.
const shownIsStored = [shownBody, resultBody, manifestBody].some(body => isShownPartOf(displayedContent, body));
if (manifest.operation_id !== selection.operationId || manifest.request_ref.project_id !== scope.projectId ||
manifest.request_ref.entity_type !== "THREAD" || manifest.request_ref.entity_id !== `request:${thread}` || manifest.request_ref.revision_digest !== scope.requestDigest ||
resultContentKey(without(resultBody, projected)) !== resultContentKey(without(manifestBody, projected)) || !isShownPartOf(displayedContent, shownBody)) mismatch();
resultContentKey(without(resultBody, projected)) !== resultContentKey(without(manifestBody, projected)) || !shownIsStored) mismatch();
}

async function read(selection: ResultSelection, displayed: Record<string, unknown>, digest: string | undefined, offset: number, expand: boolean, signal?: AbortSignal) {
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/api/resultEvidenceRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ describe.each(["stored_current", "stored_before_grounded_rows"] as const)("the a
serve(c, value => { value.result = { ...value.result, answer: "another answer" }; value.shown_result = { ...value.shown_result, answer: "another answer" }; });
await expect(show(c, { ...c.displayed, answer: "another answer" })).rejects.toThrow(BINDING);
});
// An earlier round of a conversation is shown from operation/result/read, which returns the stored result whole,
// not the summary view the latest round is shown from.
it("opens the evidence for an earlier round, which the screen shows from the stored result itself", async () => {
serve(c);
const whole = c.read.result as Wire;
// it is not the summary view: at least one of the two summarized records is there in full
expect(["absence_assessments", "checked_observations"].some(key => JSON.stringify(whole[key]) !== JSON.stringify(c.displayed[key]))).toBe(true);
expect((await show(c, whole)).selected_count).toBeGreaterThan(0);
expect((await show(c, c.read.manifest.result as Wire)).selected_count).toBeGreaterThan(0);
});
it("still rejects a whole result with another answer or an invented key, and one put together from two forms", async () => {
serve(c);
const whole = c.read.result as Wire;
await expect(show(c, { ...whole, answer: "another answer" })).rejects.toThrow(BINDING);
await expect(show(c, { ...whole, invented_key: 1 })).rejects.toThrow(BINDING);
// the summary of one record inside the whole result: each key is right in one form, the whole is right in none
const mixedKey = (["absence_assessments", "checked_observations"] as const).find(key => JSON.stringify(whole[key]) !== JSON.stringify(c.displayed[key]));
expect(mixedKey).toBeDefined();
await expect(show(c, { ...whole, [mixedKey!]: c.displayed[mixedKey!] })).rejects.toThrow(BINDING);
});
});

describe("a server that does not send shown_result or projected_keys (older)", () => {
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/api/uploadTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,3 @@ export function refusalBeforeUpload(file: Pick<File, "name" | "size">): UploadRe
if (file.size > MAX_UPLOAD_BYTES) return new UploadRefused("UPLOAD_TOO_LARGE", file.name);
return null;
}

2 changes: 1 addition & 1 deletion apps/web/src/components/ActionCompare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function ActionCompare({ result, projectId, links, discrimination }: {
const evaluations = objectList(analysis.evaluations);
const extra = [...actions.flatMap(action => stringValues(action.source_refs)), ...evaluations.flatMap(item => stringValues(item.evidence_refs))];
const numbers = numberSources(typeof result.answer === "string" ? result.answer : "", extra);
const cite = (refs: string[]) => refs.map(ref => <span className="source-number" key={ref} title={ref}>[{numbers.get(ref)}]</span>);
const cite = (refs: string[]) => refs.map(ref => <span className="source-number" key={ref}>[{numbers.get(ref)}]</span>);
if (actions.length === 0) return <p>{t("actions.empty")}</p>;
const againstNote = (action: Record<string, unknown>) =>
standingActionNote([...stringValues(action.hypothesis_ids), ...stringValues(action.hypothesis_refs)], discrimination);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/ActionEffort.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { useDraftGeneration, type DraftTicket } from "./useDraftGeneration";
import { useReadAccessProtection } from "./useReadAccessProtection";
import { message as formatMessage } from "../i18n/formatMessages";
import { t, useTranslation } from "../i18n/locale";
import { Button, Callout, HTMLSelect, InputGroup } from "@blueprintjs/core";
import { Button, Callout, InputGroup } from "@blueprintjs/core";
import { Select } from "./Select";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { objectValue, textValue } from "../api/presentation";
Expand Down Expand Up @@ -83,7 +84,7 @@ export function ActionEffort({ projectId, actionId, initial, legacy }: { project
{open && <div className="effort-editor">
{DIMENSIONS.map(dimension => <div className="effort-editor-row" key={dimension}>
<label>{effortDimensionLabel(dimension)}
<HTMLSelect aria-label={formatMessage("effortEstimate", effortDimensionLabel(dimension))} value={draft[dimension].band} onChange={event => set(dimension, "band", event.target.value)}
<Select aria-label={formatMessage("effortEstimate", effortDimensionLabel(dimension))} value={draft[dimension].band} onChange={event => set(dimension, "band", event.target.value)}
options={[{ value: "", get label() { return t("effort.none"); } }, ...effortBandOptions(dimension)]}/></label>
<InputGroup small aria-label={formatMessage("effortBasis", effortDimensionLabel(dimension))} placeholder={t("effort.basis")} value={draft[dimension].basis} onChange={event => set(dimension, "basis", event.target.value)}/>
</div>)}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/AnswerBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function AnswerBody({ answer, onCite, language }: {
const render = (items: Inline[]) => items.map((item, index) => {
const body = item.kind === "text"
? item.text
: <button type="button" className="answer-citation" title={item.spanId} aria-label={message("evidenceButton", item.n)} onClick={() => onCite(item.spanId, numbers)}>[{item.n}]</button>;
: <button type="button" className="answer-citation" title={message("evidenceButton", item.n)} aria-label={message("evidenceButton", item.n)} onClick={() => onCite(item.spanId, numbers)}>[{item.n}]</button>;
return <Fragment key={index}>{item.bold ? <strong>{body}</strong> : body}</Fragment>;
});
return <div className="answer-text" lang={language ?? undefined}>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/AnswerCitations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ it("shows numbered citations and bold text instead of internal ids and asterisks
expect(node.querySelector(".answer-text p:not(.answer-lead) strong")?.textContent).toContain("1세부");
const first = cites()[0];
expect(first.textContent).toBe("[1]");
// Neither the tooltip nor the name read aloud carries the range id; both say what the button does.
expect(first.title).toBe("근거 1 보기");
expect(first.getAttribute("aria-label")).toBe("근거 1 보기");
expect([...node.querySelectorAll("[title], [aria-label]")].filter(element => /span:/.test((element.getAttribute("title") ?? "") + (element.getAttribute("aria-label") ?? "")))).toEqual([]);
await act(async () => first.click());
expect(opened).toHaveLength(1);
expect(opened[0].kind).toBe("evidence");
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/AnswerHold.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@ import { EvidenceUseSummary } from "./EvidenceUseSummary";
* evidence can be used for. The conclusion status, the count of checks met and the counter-evidence search
* are in the tags at the head of the card, and the action needed is in the review summary below; they are
* not said a second time here. Only "no answer checks were recorded" is said here, because no tag says it.
* An earlier round never gets its checks (the server sends them for the latest request only), so it says that
* instead of "not yet".
*/
export function AnswerHold({ status, coverage, facts }: {
status: string; coverage?: CoverageMatrix | null; facts?: ReactNode;
export function AnswerHold({ status, coverage, facts, earlierRound }: {
status: string; coverage?: CoverageMatrix | null; facts?: ReactNode; earlierRound?: boolean;
}) {
const { rows, total } = coverageCounts(coverage);
return <Callout compact className="answer-hold" intent={status === "PARTIAL_HOLD" ? "warning" : "none"} role="group" aria-label={t("answerHold.title")}>
{facts}
{rows.length > 0 && <EvidenceUseSummary rows={rows}/>}
{total === 0 && <p>{t("labels.answerScope")}{" "}{t("answerHold.coverageMissing")}</p>}
{total === 0 && <p>{t("labels.answerScope")}{" "}{t(earlierRound ? "answerHold.coverageEarlier" : "answerHold.coverageMissing")}</p>}
</Callout>;
}

14 changes: 8 additions & 6 deletions apps/web/src/components/Brand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { eyebrowLabel } from "./statusLabels";
export function Brand({ hosted }: { hosted: boolean }) {
return (
<div className="brand">
<div className="brand-mark">
<Icon icon="layout-grid" size={20} />
</div>
<div>
<strong>THOTH</strong>
<span>{t("brand.tagline")}</span>
<div className="brand-name">
<div className="brand-mark">
<Icon icon="layout-grid" size={20} />
</div>
<div>
<strong>THOTH</strong>
<span>{t("brand.tagline")}</span>
</div>
</div>
<Tag minimal>{eyebrowLabel(hosted ? "REVIEW WORKSPACE" : "LOCAL WORKSPACE")}</Tag>
</div>
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/CapabilityWorkbench.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { message as formatMessage } from "../i18n/formatMessages";
import { t } from "../i18n/locale";
import { Button, Callout, Checkbox, HTMLSelect, InputGroup, Tag, TextArea } from "@blueprintjs/core";
import { Button, Callout, Checkbox, InputGroup, Tag, TextArea } from "@blueprintjs/core";
import { Select } from "./Select";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDeferredValue, useState } from "react";
import { capabilityCatalog, filterCapabilities, namespaceLabels, namespaces, parseInvocation, transportFor, type Capability } from "../api/capabilities";
import { rpc, RpcError } from "../api/rpcClient";
import { RecordInspector } from "./RecordInspector";
import { eyebrowLabel } from "./statusLabels";

function MethodDetail({ method, projectId, threadId }: { method: Capability; projectId: string; threadId?: string }) {
const client = useQueryClient();
Expand Down Expand Up @@ -66,11 +66,11 @@ export function CapabilityWorkbench({ projectId, threadId, initialNamespace = "a
);
const method = methods.find(item => item.name === selected) ?? methods[0];
return <section className="capability-workbench">
<header className="workspace-heading"><div><p className="eyebrow">{eyebrowLabel("CAPABILITY WORKBENCH")}</p><h1>{t("advanced.title")}</h1><p>{t("advanced.intro")}</p></div>
<header className="workspace-heading"><div><h1>{t("advanced.title")}</h1><p>{t("advanced.intro")}</p></div>
<Tag minimal>{capabilityCatalog.canonical_method_count} canonical + {capabilityCatalog.compatibility_alias_count} aliases</Tag></header>
<div className="catalog-toolbar"><InputGroup leftIcon="search" aria-label={t("advanced.search")} placeholder={t("advanced.searchPlaceholder")} value={search} onChange={event => setSearch(event.target.value)} />
<HTMLSelect aria-label={t("advanced.namespace")} value={namespace} onChange={event => setNamespace(event.target.value)} options={[{value:"all", label:formatMessage("namespaceCount", visibleNamespaces.length)}, ...visibleNamespaces.map(value => ({value, label:`${value} · ${namespaceLabels[value] ?? value}`}))]} />
<HTMLSelect aria-label={t("advanced.kind")} value={surface} onChange={event => setSurface(event.target.value)} options={[{value:"all",get label() { return t("advanced.allKinds"); }},{value:"QUERY",get label() { return t("advanced.queryKind"); }},{value:"COMMAND",get label() { return t("advanced.commandKind"); }},{value:"QUERY_OR_CONTROL",get label() { return t("advanced.compatibility"); }}]} /></div>
<Select aria-label={t("advanced.namespace")} value={namespace} onChange={event => setNamespace(event.target.value)} options={[{value:"all", label:formatMessage("namespaceCount", visibleNamespaces.length)}, ...visibleNamespaces.map(value => ({value, label:`${value} · ${namespaceLabels[value] ?? value}`}))]} />
<Select aria-label={t("advanced.kind")} value={surface} onChange={event => setSurface(event.target.value)} options={[{value:"all",get label() { return t("advanced.allKinds"); }},{value:"QUERY",get label() { return t("advanced.queryKind"); }},{value:"COMMAND",get label() { return t("advanced.commandKind"); }},{value:"QUERY_OR_CONTROL",get label() { return t("advanced.compatibility"); }}]} /></div>
<div className="catalog-layout"><div className="method-list" role="group" aria-label={t("advanced.list")}><div className="catalog-count">{methods.length} visible methods</div>
{methods.length === 0 && <p className="empty-copy">{t("advanced.noMatch")}</p>}
{methods.map(item => <Button alignText="left" minimal active={method?.name === item.name} key={item.name} onClick={() => setSelected(item.name)} className="method-item">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ConfirmedFacts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function ConfirmedFacts({ result, onCite, heading = t("facts.title"), onl
: findings.length === 0 ? <p className="muted">{t("facts.empty")}</p>
: <ul>{findings.map((item, index) => <li key={index}>
{item.negative && <Tag minimal>{t("facts.notFound")}</Tag>} {item.statement}{" "}
{item.refs.map(ref => <button key={ref} type="button" className="answer-citation" title={ref} aria-label={formatMessage("evidenceButton", numbers.get(ref) ?? "?")}
{item.refs.map(ref => <button key={ref} type="button" className="answer-citation" title={formatMessage("evidenceButton", numbers.get(ref) ?? "?")} aria-label={formatMessage("evidenceButton", numbers.get(ref) ?? "?")}
onClick={() => onCite(ref, numberMap)}>[{numbers.get(ref)}]</button>)}
</li>)}</ul>}
{dropped > 0 && <small className="muted">{formatMessage("excludedFacts", dropped)}</small>}
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/ConnectedSourceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,3 @@ export function ConnectedSourceCard({ projectId, artifact, sources, hosted, onCi
</article>
);
}

76 changes: 76 additions & 0 deletions apps/web/src/components/ConversationEarlierRound.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @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";

// The answer checks are sent for the latest request only. A saved round before it will never get them, so its
// card says so; "not available yet" is kept for a latest round whose checks have not arrived.
vi.mock("../api/rpcClient", async importOriginal => ({
...await importOriginal<typeof import("../api/rpcClient")>(),
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 result = (epoch: number) => ({ thread_id: "t", request_epoch: epoch, contract_version: 2, answer: epoch + "번째 답", answer_status: "PARTIAL_HOLD" });
// The live status describes the second request and carries no checks for it yet.
const status = { user_progress_summary: { schema_version: "1.0.0", request_revision_digest: digest("b"), result_revision_digest: digest("c"), state: "HOLD",
currentness: { state: "CURRENT", reasons: [], execution_eligible: true }, progress_items: [], recorded_checks: [], remaining_gaps: [], unknowns: [],
next_user_action: { schema_version: "1.0.0", action_type: "NONE", label: "", reason_codes: [], requires_permission: false, target: null, basis: {} } },
coverage_matrix: null, next_user_action: null } as unknown as ResearchStatus;
const EARLIER = "이 회차의 답변 점검 항목은 여기서 볼 수 없습니다. 최신 회차의 답에서만 보입니다.";
const NOT_YET = "답변 점검 항목 정보를 아직 읽지 못했습니다";

afterEach(async () => {
if (root) await act(async () => root!.unmount());
root = undefined; client?.clear(); container?.remove(); vi.resetAllMocks();
});

async function mount(liveStatus: ResearchStatus | undefined) {
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 epoch = Number(String(args.operation_id).split(":")[1]);
return { operation_id: "", state: "SUCCEEDED", value: { operation_id: args.operation_id, state: "SUCCEEDED", result: result(epoch) } };
}
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 act(async () => root!.render(<QueryClientProvider client={client}>
<ConversationTimeline projectId="p" threadId="t" status={liveStatus} onDetail={() => undefined}/></QueryClientProvider>));
for (let index = 0; index < 40 && !container.textContent?.includes("2번째 답"); index += 1) await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
// the first round is folded to one line; open it
const open = container.querySelector<HTMLButtonElement>(".turn-summary-actions button");
expect(open).not.toBeNull();
await act(async () => open!.click());
}
const cards = () => [...container.querySelectorAll(".conversation-turn")].map(element => element.textContent ?? "");

it("tells an earlier round that its answer checks are not shown here, and the latest round that they have not arrived", async () => {
await mount(status);
const [first, latest] = cards();
expect(first).toContain("1번째 답");
expect(first).toContain(EARLIER);
expect(first).not.toContain(NOT_YET);
expect(latest).toContain(NOT_YET);
expect(latest).not.toContain(EARLIER);
});

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).not.toContain(EARLIER);
}
});
Loading
Loading