From a004460d73d8d286856cac7fb432916581543a92 Mon Sep 17 00:00:00 2001 From: THOTH Date: Thu, 12 Nov 2026 14:18:16 +0900 Subject: [PATCH] Narrow windows, sources tab, and separate project and computer settings Narrow windows: below 760 px the project list is a drawer, the top bar stays on one line, no screen scrolls sideways at 320 or 390 px in five languages, and below 1100 px the detail panel covers the conversation. Sources tab: connected sources first, a chosen file connects at once with the local default scope, unsupported files are reported before sending, and web sources show one line and a settings link until allowed. Settings and history: project and computer settings are separate screens, research history shows records first, the new project screen keeps only the name and one button, and first-run setup offers "connect later". --- SOURCE_MANIFEST.json | 311 ++++++++++-------- apps/web/src/api/files.test.ts | 25 ++ apps/web/src/api/files.ts | 7 + apps/web/src/api/uploadTypes.ts | 47 +++ apps/web/src/components/ArchivedProjects.tsx | 60 +++- .../components/CapabilityWorkbench.test.tsx | 2 - .../ConnectFailureInWorkspace.test.tsx | 2 - .../src/components/ConnectedSourceCard.tsx | 44 +++ .../components/FileConnectionPanel.test.tsx | 94 +++++- .../src/components/FileConnectionPanel.tsx | 77 ++++- .../FileConnectionPanelParserError.test.tsx | 6 +- .../web/src/components/FirstRunSetup.test.tsx | 50 +++ apps/web/src/components/FirstRunSetup.tsx | 27 +- .../src/components/LiveProjectWorkspace.tsx | 129 ++++---- .../LiveProjectWorkspaceReadiness.test.tsx | 27 +- .../LocalRetrievalSettingsCard.test.tsx | 21 +- .../components/LocalRetrievalSettingsCard.tsx | 42 ++- .../src/components/M7StateOwnership.test.tsx | 4 - .../web/src/components/ModelSettings.test.tsx | 88 ++++- apps/web/src/components/ModelSettings.tsx | 14 +- .../src/components/NavigationDrawer.test.tsx | 233 +++++++++++++ .../components/ProjectArchiveRestore.test.tsx | 19 +- apps/web/src/components/ProjectForm.test.tsx | 32 ++ apps/web/src/components/ProjectForm.tsx | 80 +++++ .../ProjectPermanentDelete.test.tsx | 34 +- .../src/components/ProjectSessionPages.tsx | 121 +++++-- .../src/components/ProjectWebSetup.test.tsx | 7 +- apps/web/src/components/ProjectWebSetup.tsx | 129 +++++--- apps/web/src/components/ResearchComposer.tsx | 4 +- .../web/src/components/ResearchDetailPane.tsx | 6 +- apps/web/src/components/ResearchSidePanel.tsx | 1 + .../web/src/components/ResourcesPage.test.tsx | 113 +++++++ apps/web/src/components/WorkspaceTopbar.tsx | 89 +++++ .../history/HistoryChangeSummary.test.tsx | 2 +- .../components/history/HistoryTimeline.tsx | 2 +- .../components/history/ProjectReviewList.tsx | 25 +- .../history/ResearchHistoryWorkspace.test.tsx | 83 ++++- .../history/ResearchHistoryWorkspace.tsx | 75 ++++- .../web/src/components/useNavigationDrawer.ts | 63 ++++ apps/web/src/components/workspaceSelection.ts | 5 +- apps/web/src/i18n/activityMessages.ts | 8 +- apps/web/src/i18n/advancedMessages.ts | 2 +- apps/web/src/i18n/commonStateMessages.ts | 2 +- apps/web/src/i18n/formatMessages.ts | 1 - .../src/i18n/ja/researchPanelsMessagesJa.ts | 1 - apps/web/src/i18n/ja/setupMessagesJa.ts | 3 +- apps/web/src/i18n/ja/sourceMessagesJa.ts | 6 +- .../src/i18n/ja/workspaceDetailMessagesJa.ts | 1 - apps/web/src/i18n/ja/workspaceMessagesJa.ts | 24 +- apps/web/src/i18n/jaScreen.test.ts | 4 +- apps/web/src/i18n/researchPanelsMessages.ts | 4 - apps/web/src/i18n/setupMessages.ts | 15 +- apps/web/src/i18n/sourceMessages.ts | 15 +- apps/web/src/i18n/statusResources.ts | 4 +- apps/web/src/i18n/uiFormatMessages.ts | 7 +- apps/web/src/i18n/wordingRules.test.ts | 10 +- apps/web/src/i18n/workspaceDetailMessages.ts | 8 +- apps/web/src/i18n/workspaceMessages.ts | 69 ++-- .../src/i18n/zh/researchPanelsMessagesZh.ts | 1 - apps/web/src/i18n/zh/setupMessagesZh.ts | 3 +- apps/web/src/i18n/zh/sourceMessagesZh.ts | 6 +- .../src/i18n/zh/workspaceDetailMessagesZh.ts | 1 - apps/web/src/i18n/zh/workspaceMessagesZh.ts | 24 +- .../zhHant/researchPanelsMessagesZhHant.ts | 1 - .../src/i18n/zhHant/setupMessagesZhHant.ts | 3 +- .../src/i18n/zhHant/sourceMessagesZhHant.ts | 6 +- .../zhHant/workspaceDetailMessagesZhHant.ts | 1 - .../i18n/zhHant/workspaceMessagesZhHant.ts | 24 +- apps/web/src/i18n/zhHantScreen.test.ts | 4 +- apps/web/src/i18n/zhScreen.test.ts | 4 +- apps/web/src/narrowLayoutRules.test.ts | 111 +++++++ apps/web/src/workstation.css | 204 +++++++++++- config/complexity-budget.json | 9 +- src/thoth/adapters/http/file_staging.py | 15 +- .../services/history_change_summary.py | 2 +- tests/contract/test_file_upload.py | 42 +++ .../services/test_history_change_summary.py | 6 + 77 files changed, 2306 insertions(+), 545 deletions(-) create mode 100644 apps/web/src/api/uploadTypes.ts create mode 100644 apps/web/src/components/ConnectedSourceCard.tsx create mode 100644 apps/web/src/components/NavigationDrawer.test.tsx create mode 100644 apps/web/src/components/ProjectForm.test.tsx create mode 100644 apps/web/src/components/ProjectForm.tsx create mode 100644 apps/web/src/components/ResourcesPage.test.tsx create mode 100644 apps/web/src/components/WorkspaceTopbar.tsx create mode 100644 apps/web/src/components/useNavigationDrawer.ts create mode 100644 apps/web/src/narrowLayoutRules.test.ts diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index bf8cd512..5f829538 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-11-12T04:24:32.600060+00:00", + "created_at": "2026-11-12T05:07:25.409285+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -178,13 +178,13 @@ }, { "path": "apps/web/src/api/files.test.ts", - "sha256": "38574d9fb490f3858b936826cf436fe5cd91a9848531104cf61d53f25260e462", - "bytes": 8207 + "sha256": "d94b8248e2eb161f09e33bb4d39391cd845269c274213bb4a5f5c6533b5a4a95", + "bytes": 9697 }, { "path": "apps/web/src/api/files.ts", - "sha256": "c478b9ba85c6e9d503f7e63ad8fa2b73160a05bbd908c632b26bed381e365a4d", - "bytes": 3362 + "sha256": "f0a4aeea28b7b76390a172a0b1d9d90a465031ef0305dce54ce00a7fa9cc641c", + "bytes": 3739 }, { "path": "apps/web/src/api/fixtures/iris-judgment-excerpt.json", @@ -566,6 +566,11 @@ "sha256": "39f426e38364313f5fcf028abd542a7fd2e397cdea797fb5eb2e079c80d70636", "bytes": 5312 }, + { + "path": "apps/web/src/api/uploadTypes.ts", + "sha256": "55077e62dc103db1732ba91216b61990cd267e03837639fac0042e370509971d", + "bytes": 2481 + }, { "path": "apps/web/src/components/AbsenceChecks.test.tsx", "sha256": "1c653dab4036dfcd6ad4b26b1d4803bd8986ecf89497f75ff6de3845b6839170", @@ -648,8 +653,8 @@ }, { "path": "apps/web/src/components/ArchivedProjects.tsx", - "sha256": "20623d2f135e83b5942fe56848e2af044c8ae55c57eb2cd01d4f84cf0a5bfaaf", - "bytes": 4230 + "sha256": "cb52bc40c6695899cab85a49639ea903abcb6835775e4a63a6863677357a187c", + "bytes": 5399 }, { "path": "apps/web/src/components/AsyncDraftAndCitationRegression.test.tsx", @@ -673,8 +678,8 @@ }, { "path": "apps/web/src/components/CapabilityWorkbench.test.tsx", - "sha256": "2041ac3ee4b92c1f734e0aa0dcc02553cb36158f1a5f2d21337292777ba47c51", - "bytes": 7758 + "sha256": "7996237702975560f72ea146af3a86196e9337aabf07674ac7a3c361bd413395", + "bytes": 7553 }, { "path": "apps/web/src/components/CapabilityWorkbench.tsx", @@ -733,8 +738,13 @@ }, { "path": "apps/web/src/components/ConnectFailureInWorkspace.test.tsx", - "sha256": "2e46e7ac7935a64dbb2accd0f5f31b903bdbe4c98595162d48831f135bcbf670", - "bytes": 7855 + "sha256": "10764debaada2c1714a24b285d371fbfba96b40b55f8e091a03939f0d915ba75", + "bytes": 7518 + }, + { + "path": "apps/web/src/components/ConnectedSourceCard.tsx", + "sha256": "5a3f35e0fab0ed63f7ee66152b353a5cf86ee2040d8921fe5267b4cf83b15b8e", + "bytes": 2147 }, { "path": "apps/web/src/components/ContractV3Screens.test.tsx", @@ -828,28 +838,28 @@ }, { "path": "apps/web/src/components/FileConnectionPanel.test.tsx", - "sha256": "023c1e37c3f90229a892c826783b07bb711805f7926bde45cde5852e0ae54467", - "bytes": 10856 + "sha256": "ffaab549da3e7faaecbd7cefcc63a6c9aa6c4fcc75f1a7a654ea6dc5231a99c4", + "bytes": 15559 }, { "path": "apps/web/src/components/FileConnectionPanel.tsx", - "sha256": "fe10773507814be353b5aa9bdaa941ac8a7a94f5ac1094c98fed1f43fd651468", - "bytes": 8534 + "sha256": "e40358b3d92c64125d9598c51de0db8f67b8cd71be049b64679e964a09785e44", + "bytes": 10937 }, { "path": "apps/web/src/components/FileConnectionPanelParserError.test.tsx", - "sha256": "7ab66b8a61296c594988b03bf4e6bcbbee1467d70ac40acb22da5399a05c4dfe", - "bytes": 4164 + "sha256": "f0aa6794ac4f95425328a9be9cac3e4454203228548abdcb675699db5a27b58c", + "bytes": 4096 }, { "path": "apps/web/src/components/FirstRunSetup.test.tsx", - "sha256": "c41123c702275535ffd61b274c2037a5571528d307ba0ccf740b9fc671e7d91b", - "bytes": 96774 + "sha256": "e9a0b2637b2063e01913e668804996039111e874dfbf3fc465dd426e38acc70a", + "bytes": 100718 }, { "path": "apps/web/src/components/FirstRunSetup.tsx", - "sha256": "32f6c533d8c1568f4e2476ea38870c7f0a1f85420afb86dc43099cc702aa8183", - "bytes": 16384 + "sha256": "54a2e46203e7f1ac45d1b200725346c7d26b936d367eb664a3a0ef96f03f39e9", + "bytes": 16893 }, { "path": "apps/web/src/components/GroundingDisplay.test.tsx", @@ -963,8 +973,8 @@ }, { "path": "apps/web/src/components/LiveProjectWorkspace.tsx", - "sha256": "058ce6bddda4113c2441bd662a014c3d288601a15104348d183f67d67c8313c0", - "bytes": 30745 + "sha256": "770e2e7471b2bfff5da68321c6e05b46a421680e0e92da6a1bb016d658bcdb13", + "bytes": 28330 }, { "path": "apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx", @@ -978,8 +988,8 @@ }, { "path": "apps/web/src/components/LiveProjectWorkspaceReadiness.test.tsx", - "sha256": "9237e25834a63382aa718f89a826e2b398332fe63035ded3f6ad9a6dec294dd5", - "bytes": 6736 + "sha256": "15bd880777eb4c23bd5239b86cc5f5200d008005458365303596ac58483bffc0", + "bytes": 8261 }, { "path": "apps/web/src/components/LiveProjectWorkspaceRestart.test.tsx", @@ -988,13 +998,13 @@ }, { "path": "apps/web/src/components/LocalRetrievalSettingsCard.test.tsx", - "sha256": "9f6312144b4a704cd233153faf2c5ccaa875721f1b7eae2e99fdd412f1d723a8", - "bytes": 40192 + "sha256": "f1b02c08d2998748876f30d84daa6a881fc4475fef17e9e569fc9349b0aacad1", + "bytes": 41354 }, { "path": "apps/web/src/components/LocalRetrievalSettingsCard.tsx", - "sha256": "92e7866f8923c64de77b65fd4afe382d1511389ac7822ed403caf68d8d48effc", - "bytes": 23248 + "sha256": "0367452313cb4bc80c3577dd817d99e45dbb4c6602eb53f8d50c247788f47fb8", + "bytes": 24584 }, { "path": "apps/web/src/components/M7LiveReadDenial.test.tsx", @@ -1023,8 +1033,8 @@ }, { "path": "apps/web/src/components/M7StateOwnership.test.tsx", - "sha256": "b6dd441f21946c6802d97b7cb9113723f20ccca88b9b081112d4e0bc6fb1e017", - "bytes": 15230 + "sha256": "ac31b0eb3c3bf60c463a080520ae2bd154ddb7b64bc4f27f8cec5e2dd14c10e2", + "bytes": 14978 }, { "path": "apps/web/src/components/MemoryEditDialog.tsx", @@ -1043,13 +1053,18 @@ }, { "path": "apps/web/src/components/ModelSettings.test.tsx", - "sha256": "985d71c6bd452c4d118eb037b9a667872f05b99bccf861b66338e3ccfc822645", - "bytes": 24604 + "sha256": "2482b76f2217307f51d132bd7682ed726ed4a40bb626d03ad1c0e7b53a73ec91", + "bytes": 28696 }, { "path": "apps/web/src/components/ModelSettings.tsx", - "sha256": "e457583c3f8e65699c7a67c288b84d7db5fe0b8889c80a861f8f7bfdb550652c", - "bytes": 10455 + "sha256": "8f65487fb10690176b9bd5b2cdccf18615f9a8086625c532ed24a97fe050062c", + "bytes": 10717 + }, + { + "path": "apps/web/src/components/NavigationDrawer.test.tsx", + "sha256": "7fb0866c71df0cad35946a0f6e7cf81eef101893f665ae1fd93de9750aaeee05", + "bytes": 11618 }, { "path": "apps/web/src/components/OAuthLoginFlow.tsx", @@ -1063,8 +1078,18 @@ }, { "path": "apps/web/src/components/ProjectArchiveRestore.test.tsx", - "sha256": "1880fff6935d1a6d1f16532a2710eb97e3c1099db004156b4bec991ac465b3ee", - "bytes": 16660 + "sha256": "8b7a0227a4702fcdb272822a930fc622371ad0a926200b3a39955cfe8213c8c0", + "bytes": 16946 + }, + { + "path": "apps/web/src/components/ProjectForm.test.tsx", + "sha256": "070d13dc35cbb44922f48f503a7b760813f8f1789c56c96494691ee2c53c0e3c", + "bytes": 1608 + }, + { + "path": "apps/web/src/components/ProjectForm.tsx", + "sha256": "5d1764bbd5a0e00ca79e54e2e567e4bdd0024fd44d00049884718019e80267de", + "bytes": 3839 }, { "path": "apps/web/src/components/ProjectLanding.test.tsx", @@ -1093,8 +1118,8 @@ }, { "path": "apps/web/src/components/ProjectPermanentDelete.test.tsx", - "sha256": "3d1a3964888d86c2a9f0c6daf23b1047031fc6ee3a66bbeefbab3922e0e49947", - "bytes": 12003 + "sha256": "2944322e29a8969f4d429b1209a9103596dd12ac0bc4d820ab5843ed1373900d", + "bytes": 12584 }, { "path": "apps/web/src/components/ProjectPermanentDelete.tsx", @@ -1108,18 +1133,18 @@ }, { "path": "apps/web/src/components/ProjectSessionPages.tsx", - "sha256": "3fcfba48e10b32bcc103f7d2edc642eca267374420db549ee1d40b938ba32614", - "bytes": 8236 + "sha256": "10297ae274e340a734474fe4ac873d6d49a0c9bcb95bb8b9b2a06bc54039a845", + "bytes": 10850 }, { "path": "apps/web/src/components/ProjectWebSetup.test.tsx", - "sha256": "b91b32ca278b3843bf45a57cccd86ab48b7af0f68c4e77e36fe2a4b01d9d81ca", - "bytes": 8078 + "sha256": "19bf24b5e1aa2ba577a6e0aa0874a34b64e18f4be7a276c50be7039303e27962", + "bytes": 8357 }, { "path": "apps/web/src/components/ProjectWebSetup.tsx", - "sha256": "df7d14aed7bb0fdfea3939c89e22f8a853672b209bc690cc0433449a91c4fa72", - "bytes": 4569 + "sha256": "9124a46f5bf4a38c433adfb5f826a9a93097fa7ac18444735a2bb2500e3b0473", + "bytes": 6655 }, { "path": "apps/web/src/components/ReadableList.tsx", @@ -1168,13 +1193,13 @@ }, { "path": "apps/web/src/components/ResearchComposer.tsx", - "sha256": "1fd3d10268d93b2fb06f0a9444c1bb9a376c6dbf74bfd9d42bffbcf2e8668851", - "bytes": 9336 + "sha256": "08aaa902b280612188a637ad19fa7bdb763334a75a13b88d6f56366347698903", + "bytes": 9361 }, { "path": "apps/web/src/components/ResearchDetailPane.tsx", - "sha256": "ca4a80ace5b92e758d52ae62d0f882ecc62e4e971d655c4df07f8286028dbdb8", - "bytes": 5170 + "sha256": "29047d0c9974f89b9179b052a8e02cc038bfdaf9ef407303c19c9ba13a3919d5", + "bytes": 5300 }, { "path": "apps/web/src/components/ResearchEvidenceDetail.test.tsx", @@ -1218,8 +1243,13 @@ }, { "path": "apps/web/src/components/ResearchSidePanel.tsx", - "sha256": "8ab4da7debae05eb38cd3c65b565f1e62dcfdc527e04ff394db8d08cecc73fb6", - "bytes": 2860 + "sha256": "bcdb3d9bfe291d06ed3c6e647f7aa983150c1755dca86520f706470bf9c68c12", + "bytes": 2898 + }, + { + "path": "apps/web/src/components/ResourcesPage.test.tsx", + "sha256": "2e827a36757498a1df4bf07bddbcffc383cdf9829531b695338f53a1457aa57d", + "bytes": 6748 }, { "path": "apps/web/src/components/ResultCardOnce.test.tsx", @@ -1461,6 +1491,11 @@ "sha256": "1df2ab32bfee5878e17ae7464c8fdadbb5f4192357d53078c3c26e9f818d9977", "bytes": 599 }, + { + "path": "apps/web/src/components/WorkspaceTopbar.tsx", + "sha256": "ac9075e74c3802b1153498c6012aaeff44268772d03d8435d39dc00e7470e6c8", + "bytes": 3697 + }, { "path": "apps/web/src/components/XaiDeviceLogin.tsx", "sha256": "3406ed5f6055ce25a529b0735351de002af35860bf32b375c74653165dfa3c9c", @@ -1578,8 +1613,8 @@ }, { "path": "apps/web/src/components/history/HistoryChangeSummary.test.tsx", - "sha256": "d38697c24be276f1e47e53c80edf9d1ea9d100c14857d1b38610e1043b0fea9e", - "bytes": 5371 + "sha256": "667774c50ec600d9e2cce95f49adbc5bf808fff26a7ab669ce5e3cf014596d80", + "bytes": 5378 }, { "path": "apps/web/src/components/history/HistoryChangeSummary.tsx", @@ -1603,8 +1638,8 @@ }, { "path": "apps/web/src/components/history/HistoryTimeline.tsx", - "sha256": "82b49c607dcf62dff70a7b4863c71e5f966a9e7d656f96cfa078307476e98a80", - "bytes": 4279 + "sha256": "8c60c1574aaa4c609d8892702e1912a8fe4e2e286c44ec5b196fe4a4c416c9b5", + "bytes": 4284 }, { "path": "apps/web/src/components/history/ProjectReviewList.test.tsx", @@ -1613,18 +1648,18 @@ }, { "path": "apps/web/src/components/history/ProjectReviewList.tsx", - "sha256": "e8186f312dbe46247d736f9ffb7fd9cbd718be3994573d30e4ba74666ce797f0", - "bytes": 3764 + "sha256": "941f126398b6a5f28bd5ad68a8852a5d97d32d421a82daa16a5c1cef777d2832", + "bytes": 4555 }, { "path": "apps/web/src/components/history/ResearchHistoryWorkspace.test.tsx", - "sha256": "3c6ab3cb3ce96563c513d73706cf11fbb9678359e56822aa66e5ad2df6739988", - "bytes": 26964 + "sha256": "7c2d2fd5d7cc4c9773c8ad69d2d71cf4d61f61ba4e05eb56bc583c6b7a02f5cf", + "bytes": 32288 }, { "path": "apps/web/src/components/history/ResearchHistoryWorkspace.tsx", - "sha256": "6786583d26f14a576a6528a0f5e4dc0c7303013ed1a27539f0290b4927968a88", - "bytes": 8825 + "sha256": "2d4c02e54a6881ca7566fe422a673dfd585080f300887a3b7e9fd7ee630b6bc0", + "bytes": 11236 }, { "path": "apps/web/src/components/history/RestorePreviewDialog.tsx", @@ -1891,6 +1926,11 @@ "sha256": "460b0f47cb64f71e11923da747b77c2186ef299181ea33d3c8f86eb96f143184", "bytes": 2365 }, + { + "path": "apps/web/src/components/useNavigationDrawer.ts", + "sha256": "29fddd758ba9646a2e471fb41e92eb28ec2469c8722a32d92221f5db07c3afae", + "bytes": 2324 + }, { "path": "apps/web/src/components/useOpenAtLatestTurn.test.tsx", "sha256": "4f36af77b87d04dd960bd3512ff992c129c0ff2787fe47f4946c935bc37573d4", @@ -1933,8 +1973,8 @@ }, { "path": "apps/web/src/components/workspaceSelection.ts", - "sha256": "5538d516f5d1d296b75159e7201292625ce55e7291bd48929c62fed8c7e514a5", - "bytes": 8535 + "sha256": "87058f033e3135ed8433e4604d48b2abc59d0932c1998e0cc29800ab43d74320", + "bytes": 8928 }, { "path": "apps/web/src/components/workspaceSetupIssue.ts", @@ -1963,13 +2003,13 @@ }, { "path": "apps/web/src/i18n/activityMessages.ts", - "sha256": "f88beb126a6e11412d5a76df2fbd28b569c1b99ed407debbdbc3e1183fa5742e", - "bytes": 17902 + "sha256": "57b12d48b835d29aad6e2689d089d3810eb871a7d03e48117977bd988e163e7e", + "bytes": 17925 }, { "path": "apps/web/src/i18n/advancedMessages.ts", - "sha256": "588d96e9126d607af2a9a3193272fb143d7fb535b5f1e1d5c69c899d9d37207f", - "bytes": 13914 + "sha256": "3944b37755ec77b9918b40970a157d23cd700558ac9b3653043cb1d56b3565c1", + "bytes": 13921 }, { "path": "apps/web/src/i18n/apiNoticesMessages.ts", @@ -1983,7 +2023,7 @@ }, { "path": "apps/web/src/i18n/commonStateMessages.ts", - "sha256": "6b54ecc5e5fae44c60bc6df1fbe71fb899845f9b4497eff7ad392cc3ed62a031", + "sha256": "d932cfa9d75cf0095eabb21d53d829dfcb95cb072f8d6e576c650ebd16a6b5fb", "bytes": 6105 }, { @@ -2018,8 +2058,8 @@ }, { "path": "apps/web/src/i18n/formatMessages.ts", - "sha256": "4a1a5018e1116f0effd5e40cdf3bac89d5182c463719bc4b71c684cb50a3348d", - "bytes": 9707 + "sha256": "120281078e143fb3fa6cf0db17f1cf823115bfafd3af34714cd1b21938b4d8ed", + "bytes": 9430 }, { "path": "apps/web/src/i18n/groundingMessages.ts", @@ -2158,8 +2198,8 @@ }, { "path": "apps/web/src/i18n/ja/researchPanelsMessagesJa.ts", - "sha256": "09436e417bf413bdbafc52f8f7303fd45d9a22bd5f048877ae3fef15c97b2d12", - "bytes": 8113 + "sha256": "fe84da9e90dd4a64b322476e1329ee9a0e0c2aa968ba3afd39c8ba664ade1eb1", + "bytes": 8012 }, { "path": "apps/web/src/i18n/ja/reviewPanelsMessagesJa.ts", @@ -2168,13 +2208,13 @@ }, { "path": "apps/web/src/i18n/ja/setupMessagesJa.ts", - "sha256": "ef0e850636f5a9e01baa2d7f8c71e625ef430542acca6a3336b30af4d3be6838", - "bytes": 5549 + "sha256": "516ed796e5cc40bf9aef22bc83e71c05e2dd9931e6950160092a83de715dc144", + "bytes": 5676 }, { "path": "apps/web/src/i18n/ja/sourceMessagesJa.ts", - "sha256": "c701508c8dcb78dbf8559630de5bbe24710ad4e94b96bf9d96e2d1c082fa2e29", - "bytes": 7315 + "sha256": "ea44170054de7bfc4fc91d81202df59c202a42b20db5a9b83f0d67357404e9df", + "bytes": 7567 }, { "path": "apps/web/src/i18n/ja/traceMessagesJa.ts", @@ -2183,18 +2223,18 @@ }, { "path": "apps/web/src/i18n/ja/workspaceDetailMessagesJa.ts", - "sha256": "aad6b2abc7de53bb9d89a864b9f07dcd9c2ce405bb0b3d02feeb82664cd4baf8", - "bytes": 2951 + "sha256": "1b0417540e64556ee8142118dbe7c10b987b315325f376de3b54f47a9934c6de", + "bytes": 2861 }, { "path": "apps/web/src/i18n/ja/workspaceMessagesJa.ts", - "sha256": "00a5124f5457b1579147b19b9af08a858439b2785a41866f19ad2f97e1604a99", - "bytes": 9634 + "sha256": "76d10168f7ba91bfe7b2b287e9ce5422dc2085c2f1cb4536da9ed6b5d91fe308", + "bytes": 10035 }, { "path": "apps/web/src/i18n/jaScreen.test.ts", - "sha256": "f1fc95c4f6fe5804a15611562cd22a279960504adcdd4c1b1310a022ab87275c", - "bytes": 8503 + "sha256": "539b18d87f1f64b4436a454528b84e7cfb95351e73ecd3c5b62a27073fea4e99", + "bytes": 8702 }, { "path": "apps/web/src/i18n/judgmentMessages.ts", @@ -2303,8 +2343,8 @@ }, { "path": "apps/web/src/i18n/researchPanelsMessages.ts", - "sha256": "3a1920a2f85285d2feb561d5b5a28f569e9a79fbbe294da420b212ed2428dd88", - "bytes": 13957 + "sha256": "57ed43f2962b7ad05216b1072b896d3ba352cee9b369facf4ad90278c346b00d", + "bytes": 13776 }, { "path": "apps/web/src/i18n/reviewPanelsMessages.ts", @@ -2318,13 +2358,13 @@ }, { "path": "apps/web/src/i18n/setupMessages.ts", - "sha256": "9dd3eb7b208720918631b44ff6d5ac856257af99b2512cec13df37b66be0b3ae", - "bytes": 8955 + "sha256": "8c5848880465020a4df189061d3580c8a98a1586c9e87e4977056d8e61253f7b", + "bytes": 9197 }, { "path": "apps/web/src/i18n/sourceMessages.ts", - "sha256": "6219c50edc23746c884e4bb6e3b2124b853df2496ab9eea9481599ab10ef9aae", - "bytes": 11469 + "sha256": "5030d7a6e025496f03c72290402394b5bbe7698d8a58dc00888d1946d12043c1", + "bytes": 11986 }, { "path": "apps/web/src/i18n/stateFormatMessages.ts", @@ -2348,8 +2388,8 @@ }, { "path": "apps/web/src/i18n/statusResources.ts", - "sha256": "8234e9fcdc48195046d817ad5025d1c5b48595a127269f63f7b9508361ba46e4", - "bytes": 6816 + "sha256": "7cfa0b6fd0aac8e97cc2ca12047c468dd922d05c660bac6cf0573ae2aee6b38e", + "bytes": 6823 }, { "path": "apps/web/src/i18n/statusTraditional.ts", @@ -2398,8 +2438,8 @@ }, { "path": "apps/web/src/i18n/uiFormatMessages.ts", - "sha256": "a5f524c991f427b9302246178a924bf05d170eeeb8b1e90a3165bbe26d3df262", - "bytes": 15489 + "sha256": "2e3b587d28f4abc90e197eba248eec2077bd772c4f8e61fa66a12b6288d23060", + "bytes": 18591 }, { "path": "apps/web/src/i18n/withColumns.ts", @@ -2408,18 +2448,18 @@ }, { "path": "apps/web/src/i18n/wordingRules.test.ts", - "sha256": "dae5b3539d063daa8b86f566e3a4699bc83df9911bcb571d25b0d1fd4576cfd6", - "bytes": 5614 + "sha256": "207dba26218511df5436ec887d9864884a9d51f0456ae957aea3a51f505f697e", + "bytes": 6125 }, { "path": "apps/web/src/i18n/workspaceDetailMessages.ts", - "sha256": "8eea6dbd87313f49cca538c6fb1f20b11e5297a6a42badb0de1d94038877ed8e", - "bytes": 4395 + "sha256": "f116444a7d00c2106ceb7de6454795b8b9622abc1d87d7fa056fd9519a34ca40", + "bytes": 4182 }, { "path": "apps/web/src/i18n/workspaceMessages.ts", - "sha256": "ab4253e9772a46a6ba894701dffd0effd01e18886ee4bc0ab703f4c0f1f3f475", - "bytes": 15895 + "sha256": "cb3b4086e4991309364e6019da2434a33151f29ed4b3277b28cecbd26e057a6f", + "bytes": 16259 }, { "path": "apps/web/src/i18n/zh/activityMessagesZh.ts", @@ -2523,8 +2563,8 @@ }, { "path": "apps/web/src/i18n/zh/researchPanelsMessagesZh.ts", - "sha256": "db082dcd494fb3573ef8784e922fc1f29c30fbc0d264328db6f7a79815e11307", - "bytes": 6420 + "sha256": "ca2832414a4f96b864a3fea443fd176b170c93e84bb75f3d3a276325e94df242", + "bytes": 6334 }, { "path": "apps/web/src/i18n/zh/reviewPanelsMessagesZh.ts", @@ -2533,13 +2573,13 @@ }, { "path": "apps/web/src/i18n/zh/setupMessagesZh.ts", - "sha256": "c2ab69a990e97cbabb935630f1c38abc294c18004eb8052091662165d9fe8a6b", - "bytes": 4167 + "sha256": "e83df1e0aae2988f459a634aea9c95551cac098ac9899738691497432900f76b", + "bytes": 4270 }, { "path": "apps/web/src/i18n/zh/sourceMessagesZh.ts", - "sha256": "25e6f51454c537e8115685ced6094953407767124a21c55a8af6a50a8f26adb0", - "bytes": 5181 + "sha256": "7f2dd61e841a5d38f6bb499b2e6f1bc6ef3e3bba3a31c8e0b63caf9d690412d6", + "bytes": 5379 }, { "path": "apps/web/src/i18n/zh/traceMessagesZh.ts", @@ -2548,13 +2588,13 @@ }, { "path": "apps/web/src/i18n/zh/workspaceDetailMessagesZh.ts", - "sha256": "4e5ceb342d010dbabbb2460e410d1548db0348d0c39244c59e2f72eedfbcd4bd", - "bytes": 2180 + "sha256": "6ac9fbb075a867ea639f333d33d296dfb307622c00e2ab71eccf6804f644c37a", + "bytes": 2090 }, { "path": "apps/web/src/i18n/zh/workspaceMessagesZh.ts", - "sha256": "e5cce454c134aac2cdbb9a7dc115e15c10e1b306f5ff3ca5ad57c6bde4d7e466", - "bytes": 7571 + "sha256": "cc4d11e3865c68ef38315a9768f73a32799bb5476749b5a0579b5991c98b23e4", + "bytes": 7762 }, { "path": "apps/web/src/i18n/zhHant/activityMessagesZhHant.ts", @@ -2658,8 +2698,8 @@ }, { "path": "apps/web/src/i18n/zhHant/researchPanelsMessagesZhHant.ts", - "sha256": "e3af4904720d056f82d54ffd5543e48115ed829bf70390f72141ef8755efd402", - "bytes": 6433 + "sha256": "40bf783f3463425e458513c778f0583642a3ed1d996cfe95870ed2995809976a", + "bytes": 6347 }, { "path": "apps/web/src/i18n/zhHant/reviewPanelsMessagesZhHant.ts", @@ -2668,13 +2708,13 @@ }, { "path": "apps/web/src/i18n/zhHant/setupMessagesZhHant.ts", - "sha256": "95c95f4dbcdc1464a7ef5bcde4200d69079fb0bc8731b8403e51648d6901ccfe", - "bytes": 4183 + "sha256": "17de5df08840fd06702d9bd5b33b65c27e164bfdbd6cbe5d8cfd52fdceedc00d", + "bytes": 4286 }, { "path": "apps/web/src/i18n/zhHant/sourceMessagesZhHant.ts", - "sha256": "f255174e45be3c4e62eb0d4b9aa4849ccf67cd9d233b2f38d986be9d6e5cf1c1", - "bytes": 5194 + "sha256": "1f73ea09a2d9aa74754d06e3d6ee346d2ce81347e14af571dae9cb07ea6acee7", + "bytes": 5392 }, { "path": "apps/web/src/i18n/zhHant/traceMessagesZhHant.ts", @@ -2683,29 +2723,34 @@ }, { "path": "apps/web/src/i18n/zhHant/workspaceDetailMessagesZhHant.ts", - "sha256": "29bbf0f5915a1774d372222b415227625f204ac638b3410698c8c10a12f0b5cc", - "bytes": 2178 + "sha256": "350483fb00cd2d71dae4d692faa4d6e2b150eed6cf63c0e00ad482bd28b42b9f", + "bytes": 2088 }, { "path": "apps/web/src/i18n/zhHant/workspaceMessagesZhHant.ts", - "sha256": "0fdd96eb0b0a29b2120498660807e44e75682570c2ce06a912e922bf7cea870a", - "bytes": 7605 + "sha256": "684ed1113359a14bbcaec321d8326cafd2cec4619f1f2a767732f485310ea615", + "bytes": 7796 }, { "path": "apps/web/src/i18n/zhHantScreen.test.ts", - "sha256": "66c8bbd6aaf75ffdb136a6dba23e1d4ddf3e1c5d17159cde7da72b7555cc8d74", - "bytes": 11039 + "sha256": "d9ce56dbd09c7a97a96aed81674cb48094d48bfad3daaa578155626736227478", + "bytes": 11238 }, { "path": "apps/web/src/i18n/zhScreen.test.ts", - "sha256": "eccfbc7ffa4746ad41a44222ad5ef740661b412ca1378ac47b577137946514f5", - "bytes": 8604 + "sha256": "84da741c0497b7e6438074737ace8da301d13b046b4f072407fa60e9a574e9b7", + "bytes": 8803 }, { "path": "apps/web/src/main.tsx", "sha256": "6f59adbfa6ce1c5c7b965d66c51c44bdcfb11c1bbb97d48150025d8ee8987b4a", "bytes": 673 }, + { + "path": "apps/web/src/narrowLayoutRules.test.ts", + "sha256": "ed05cbc9cf5895079bb33b327fcb731e16eb3d515a0b7327591d7d8d5beb4559", + "bytes": 5845 + }, { "path": "apps/web/src/preview-timeline.tsx", "sha256": "035099afc0c013b87bd2f61e408b064e7c4d9c8fb50545e6b3d27339b52a8d1e", @@ -2753,8 +2798,8 @@ }, { "path": "apps/web/src/workstation.css", - "sha256": "17a20f800162e9dd747c409f22c25850f53dc1173f69f72bd4836af6f6538982", - "bytes": 66640 + "sha256": "d327d41beb1ec4600411998e316db83a68b2f3eb1dceb967eeb33cfc5b09253e", + "bytes": 72779 }, { "path": "apps/web/timeline-preview.html", @@ -2803,8 +2848,8 @@ }, { "path": "config/complexity-budget.json", - "sha256": "6d1d2c2e903e1eeb2630073ee6f3ec74460b4bb8a05019bd088832ec6746be00", - "bytes": 5436 + "sha256": "7cad9a320f603d51bb9ed7dfe70029f1eb3cc286f86168cd87227e1f16ecf2ea", + "bytes": 5362 }, { "path": "config/connectors/local-reference.yaml", @@ -4498,8 +4543,8 @@ }, { "path": "src/thoth/adapters/http/file_staging.py", - "sha256": "476b69ee7fb880a69ffa42c2e218b736bc07817f733628ba999aab986bb54c7a", - "bytes": 5531 + "sha256": "ed7ac7f946e47aa148c2ed3866d3695a779761acded5882b7564d1082b285812", + "bytes": 5950 }, { "path": "src/thoth/adapters/http/hosted_review.py", @@ -6313,7 +6358,7 @@ }, { "path": "src/thoth/application/services/history_change_summary.py", - "sha256": "7e6e867722a006ac8fc92be3b20f569f745f68abfdf9664e903e833a139ce606", + "sha256": "6ffcb7843213efd384c5611db5aa9f7f7f07da6c84717d45379ca8e420717af4", "bytes": 8901 }, { @@ -9273,8 +9318,8 @@ }, { "path": "tests/contract/test_file_upload.py", - "sha256": "5ea6cdb29e76b7793e1650789e7224f69689a85b7d36134f9086e1201c488a1e", - "bytes": 5487 + "sha256": "e351c603df0690549f86dbc024ce28a141a6bbc151eefa78ae5fe14b6b3680a0", + "bytes": 7583 }, { "path": "tests/contract/test_http_async_route_contract.py", @@ -12928,8 +12973,8 @@ }, { "path": "tests/unit/services/test_history_change_summary.py", - "sha256": "1802c58e727470625581ff23be1aa50c755b6603b78cefa0602b4f8ee50dc29d", - "bytes": 6539 + "sha256": "31624aa87c24e01b8baca59f384324cf6340b8af4e078bbfdddd1438dfc7c33e", + "bytes": 6775 }, { "path": "tests/unit/services/test_hypothesis_auxiliary_drafts.py", diff --git a/apps/web/src/api/files.test.ts b/apps/web/src/api/files.test.ts index ac6bc7c6..274bfbdf 100644 --- a/apps/web/src/api/files.test.ts +++ b/apps/web/src/api/files.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { stageFile, type StagedFile } from "./files"; import { setLocale } from "../i18n/locale"; +import { UploadRefused } from "./uploadTypes"; type Progress = { phase: "uploading" | "processing"; loaded: number; total: number | null }; type Options = { signal?: AbortSignal; onProgress?: (progress: Progress) => void }; @@ -18,6 +19,8 @@ class FakeXHR { ontimeout: null | (() => void) = null; status = 0; responseText = ""; + responseHeaders: Record = {}; + getResponseHeader = (name: string) => this.responseHeaders[name.toLowerCase()] ?? null; timeout = 0; body: FormData | null = null; open = vi.fn(); @@ -163,6 +166,28 @@ it.each([[413, '{"detail":"server detail"}', "server detail"], [503, "not json", }, ); +it("turns a refusal the server names by code into one that names the file, and keeps the old sentence otherwise", async () => { + const refused = start(); + FakeXHR.instances[0].responseHeaders = { "x-thoth-upload-error": "UPLOAD_UNSUPPORTED_TYPE" }; + FakeXHR.instances[0].complete(415, JSON.stringify({ detail: "unsupported research file type" })); + await refused.done; + expect(refused.result.error).toBeInstanceOf(UploadRefused); + expect(refused.result.error).toMatchObject({ code: "UPLOAD_UNSUPPORTED_TYPE", filename: "a.md" }); + expect(refused.result.error?.message).toContain('"a.md" is not a file type that can be uploaded.'); + setLocale("ko"); + expect(refused.result.error?.message).toContain('"a.md" 파일은 올릴 수 없는 형식입니다.'); + setLocale("en"); + // A code this screen does not know, or no code: the server's own sentence, as before. + FakeXHR.instances = []; + const other = start(); + FakeXHR.instances[0].responseHeaders = { "x-thoth-upload-error": "A_CODE_FROM_A_NEWER_SERVER" }; + FakeXHR.instances[0].complete(409, JSON.stringify({ detail: "staged digest collision" })); + await other.done; + expect(other.result.error).not.toBeInstanceOf(UploadRefused); + expect(other.result.error?.message).toBe("staged digest collision"); + await expectNoUploadTimer(); +}); + it("cleans up after malformed successful JSON", async () => { const { result, done } = start(); xhr().complete(200, "invalid json"); await done; expect(result.error).toBeInstanceOf(SyntaxError); diff --git a/apps/web/src/api/files.ts b/apps/web/src/api/files.ts index b1a26d0b..70e76451 100644 --- a/apps/web/src/api/files.ts +++ b/apps/web/src/api/files.ts @@ -1,5 +1,6 @@ import { message as formatMessage } from "../i18n/formatMessages"; import { UiError } from "../i18n/UiError"; +import { isUploadRefusalCode, UPLOAD_ERROR_HEADER, UploadRefused } from "./uploadTypes"; export type StagedFile = { relative_path: string; filename: string; @@ -62,6 +63,12 @@ export function stageFile(file: File, projectId: string, options: StageFileOptio xhr.onload = () => { if (settled) return; if (xhr.status < 200 || xhr.status >= 300) { + // A refusal the server names by code is said in the screen language, with the file's name. + const code = xhr.getResponseHeader(UPLOAD_ERROR_HEADER); + if (isUploadRefusalCode(code)) { + finish(new UploadRefused(code, file.name)); + return; + } let detail: { detail?: string } | null = null; try { detail = JSON.parse(xhr.responseText); } catch { /* Preserve the HTTP fallback. */ } finish(new Error(detail?.detail ?? formatMessage("filePrepareFailed", xhr.status))); diff --git a/apps/web/src/api/uploadTypes.ts b/apps/web/src/api/uploadTypes.ts new file mode 100644 index 00000000..4bdfbc37 --- /dev/null +++ b/apps/web/src/api/uploadTypes.ts @@ -0,0 +1,47 @@ +import { message } from "../i18n/formatMessages"; + +/** + * What the server stages (adapters/http/file_staging.py: MEDIA_TYPES, MAX_UPLOAD_BYTES). The screen keeps + * a copy so the file dialog can filter and a wrong file is refused before it is sent; a server test + * compares the two lists. + */ +export const UPLOAD_EXTENSIONS = [".hwpx", ".pdf", ".docx", ".xlsx", ".csv", ".json", ".md", ".txt", ".yaml", ".yml", ".html", ".htm"] as const; +export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024; +/** The value of the file input's accept attribute. */ +export const UPLOAD_ACCEPT = UPLOAD_EXTENSIONS.join(","); +/** The same types as people name them, for sentences. */ +export const UPLOAD_FORMATS = "HWPX · PDF · DOCX · XLSX · CSV · JSON · MD · TXT · YAML · HTML"; +/** The response header in which the server names why it refused an upload. */ +export const UPLOAD_ERROR_HEADER = "x-thoth-upload-error"; + +const REFUSALS = ["UPLOAD_UNSUPPORTED_TYPE", "UPLOAD_EMPTY", "UPLOAD_TOO_LARGE"] as const; +export type UploadRefusalCode = (typeof REFUSALS)[number]; + +export function isUploadRefusalCode(value: string | null): value is UploadRefusalCode { + return REFUSALS.some(code => code === value); +} + +/** A file that cannot be uploaded: which file and why, said in the screen language at the time it is shown. */ +export class UploadRefused extends Error { + constructor(readonly code: UploadRefusalCode, readonly filename: string) { + super(code); + Object.defineProperty(this, "message", { configurable: true, get: () => uploadRefusalText(code, filename) }); + } +} + +export function uploadRefusalText(code: UploadRefusalCode, filename: string): string { + if (code === "UPLOAD_UNSUPPORTED_TYPE") return message("uploadUnsupported", filename, UPLOAD_FORMATS); + if (code === "UPLOAD_EMPTY") return message("uploadEmpty", filename); + return message("uploadTooLarge", filename); +} + +/** The refusal the server would give this file, known before anything is sent. */ +export function refusalBeforeUpload(file: Pick): UploadRefused | null { + const dot = file.name.lastIndexOf("."); + const extension = dot === -1 ? "" : file.name.slice(dot).toLowerCase(); + if (!UPLOAD_EXTENSIONS.some(item => item === extension)) return new UploadRefused("UPLOAD_UNSUPPORTED_TYPE", file.name); + if (file.size === 0) return new UploadRefused("UPLOAD_EMPTY", file.name); + if (file.size > MAX_UPLOAD_BYTES) return new UploadRefused("UPLOAD_TOO_LARGE", file.name); + return null; +} + diff --git a/apps/web/src/components/ArchivedProjects.tsx b/apps/web/src/components/ArchivedProjects.tsx index b8c76969..a6fed18a 100644 --- a/apps/web/src/components/ArchivedProjects.tsx +++ b/apps/web/src/components/ArchivedProjects.tsx @@ -1,6 +1,6 @@ import { message } from "../i18n/formatMessages"; import { t, useTranslation } from "../i18n/locale"; -import { Button, Callout } from "@blueprintjs/core"; +import { Button, Callout, PopoverNext } from "@blueprintjs/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { rpc } from "../api/rpcClient"; @@ -12,6 +12,45 @@ import type { ProjectSummary } from "../types"; import { ProjectPermanentDelete } from "./ProjectPermanentDelete"; import { useReadAccessProtection } from "./useReadAccessProtection"; +/** + * One archived project: its name and the same kind of menu the open projects have. Restoring and deleting + * sit in the menu, so a permanent delete is not one slip away from a restore. + */ +function ArchivedProjectRow({ project, restoring, busy, onRestore, onDelete }: { + project: ProjectSummary; + restoring: boolean; + busy: boolean; + onRestore: () => void; + /** Left out where projects cannot be deleted (the hosted review environment). */ + onDelete?: () => void; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const choose = (action: () => void) => () => { + setMenuOpen(false); + action(); + }; + const menu = ( +
+ + {onDelete && ( + + )} +
+ ); + return ( +
+ {project.name} + +
+ ); +} + /** Archived projects are read-only and hidden from the list; this shows them on demand so one can be restored. */ export function ArchivedProjects({ scopeKey, hosted = false, storageScope, onDeleted }: { scopeKey: string; @@ -47,15 +86,16 @@ export function ArchivedProjects({ scopeKey, hosted = false, storageScope, onDel {!blocked && archived.isPending &&

{t("archive.loading")}

} {(archived.error || blocked) && {t("archive.failed")}} {!blocked && archived.isSuccess && rows.length === 0 &&

{t("archive.empty")}

} - {rows.map(item =>
- {item.name} - - - {!hosted && } - -
)} + {rows.map(item => ( + restore.mutate(item)} + onDelete={hosted ? undefined : () => { setDeleted(false); setDeleting(item); }} + /> + ))} {!blocked && } {deleted && !deleting &&

{t("archive.deleteDone")}

} } diff --git a/apps/web/src/components/CapabilityWorkbench.test.tsx b/apps/web/src/components/CapabilityWorkbench.test.tsx index b6823a82..8e6a3f42 100644 --- a/apps/web/src/components/CapabilityWorkbench.test.tsx +++ b/apps/web/src/components/CapabilityWorkbench.test.tsx @@ -99,8 +99,6 @@ it("successful file connect resets the native chooser and permits selecting the Object.defineProperty(input,"files",{configurable:true,value:[file]}); await act(async()=>{ input.dispatchEvent(new Event("change",{bubbles:true})); - const visibility=container.querySelector('[aria-label="자료 사용 범위"]')!; - visibility.value="PROJECT_SHARED";visibility.dispatchEvent(new Event("change",{bubbles:true})); const authority=container.querySelector('[aria-label="자료 성격"]')!; authority.value="INFORMAL";authority.dispatchEvent(new Event("change",{bubbles:true})); }); diff --git a/apps/web/src/components/ConnectFailureInWorkspace.test.tsx b/apps/web/src/components/ConnectFailureInWorkspace.test.tsx index 7bdf9a1f..5993f98a 100644 --- a/apps/web/src/components/ConnectFailureInWorkspace.test.tsx +++ b/apps/web/src/components/ConnectFailureInWorkspace.test.tsx @@ -65,8 +65,6 @@ async function openSourcesAndConnect() { const input = container.querySelector('.source-connect-panel input[type="file"]')!; Object.defineProperty(input, "files", { configurable: true, value: [new File(["x"], "gb.txt")] }); await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); - const scope = container.querySelector('.source-connect-panel select[aria-label="사용 범위"]') ?? [...container.querySelectorAll(".source-connect-panel select")].at(-1)!; - await act(async () => { scope.value = "PROJECT_SHARED"; scope.dispatchEvent(new Event("change", { bubbles: true })); }); await act(async () => { ([...container.querySelectorAll(".source-connect-panel button")].find(item => item.textContent === "자료 연결") as HTMLButtonElement).click(); }); } diff --git a/apps/web/src/components/ConnectedSourceCard.tsx b/apps/web/src/components/ConnectedSourceCard.tsx new file mode 100644 index 00000000..bf282e7d --- /dev/null +++ b/apps/web/src/components/ConnectedSourceCard.tsx @@ -0,0 +1,44 @@ +import { Button, Collapse } from "@blueprintjs/core"; +import { useState } from "react"; +import { cutoffLabel } from "../api/sourceTime"; +import { t } from "../i18n/locale"; +import { isHttpUrl, sourceCardHost, sourceCardTitle } from "../sourceDisplay"; +import { RecordInspector } from "./RecordInspector"; +import { SourceDisconnectButton } from "./SourceDisconnect"; +import { SourceTimeAdvanced } from "./SourceTimeAdvanced"; +import type { ProjectSources } from "./useProjectSources"; + +/** One connected source: what it is, whether it may be cited, and the actions on it. */ +export function ConnectedSourceCard({ projectId, artifact, sources, hosted, onCite }: { + projectId: string; + artifact: ProjectSources["artifacts"][number]; + sources: ProjectSources | undefined; + hosted: boolean; + onCite: () => void; +}) { + const [detailsOpen, setDetailsOpen] = useState(false); + const assessment = (sources?.source_times ?? []).find(item => item.artifact_id === artifact.artifact_id); + const title = sourceCardTitle(artifact.source_uri); + const host = sourceCardHost(artifact.source_uri); + return ( +
+

{isHttpUrl(artifact.source_uri) ? {title} : title}

+ {host ?

{host}

: null} +

{artifact.media_type}

+

{cutoffLabel(assessment?.cutoff_state ?? artifact.cutoff_state ?? "UNKNOWN_TIME")}

+ + + {!hosted && } + {" "} + {!hosted && ( + <> + + + + )} +
+ ); +} + diff --git a/apps/web/src/components/FileConnectionPanel.test.tsx b/apps/web/src/components/FileConnectionPanel.test.tsx index a87b2d00..661ba56c 100644 --- a/apps/web/src/components/FileConnectionPanel.test.tsx +++ b/apps/web/src/components/FileConnectionPanel.test.tsx @@ -5,11 +5,12 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { FileConnectionPanel } from "./FileConnectionPanel"; import { setLocale, t } from "../i18n/locale"; +import { UPLOAD_ACCEPT, UploadRefused } from "../api/uploadTypes"; type Progress = { phase: "uploading" | "processing"; loaded: number; total: number | null }; type Options = { signal?: AbortSignal; onProgress?: (progress: Progress) => void }; -type Job = { name: string; options?: Options; finish: () => void; fail: () => void }; -const fixture = vi.hoisted(() => ({ jobs: [] as Job[], calls: [] as string[], connectGate: null as Promise | null, failConnect: "" })); +type Job = { name: string; options?: Options; finish: () => void; fail: (error?: Error) => void }; +const fixture = vi.hoisted(() => ({ jobs: [] as Job[], calls: [] as string[], scopes: [] as string[], connectGate: null as Promise | null, failConnect: "" })); vi.mock("../api/files", () => ({ stageFile: (file: File, _project: string, options?: Options) => new Promise((resolve, reject) => { fixture.calls.push(`stage:${file.name}`); const cancel = () => reject(new Error("synthetic upload cancelled")); @@ -17,10 +18,11 @@ vi.mock("../api/files", () => ({ stageFile: (file: File, _project: string, optio fixture.jobs.push({ name: file.name, options, finish: () => { options?.signal?.removeEventListener("abort", cancel); resolve({ relative_path: `web/${file.name}`, filename: file.name, media_type: "text/markdown", byte_sha256: "a".repeat(64), bytes: file.size }); - }, fail: () => reject(new Error("synthetic upload stalled")) }); + }, fail: (error?: Error) => reject(error ?? new Error("synthetic upload stalled")) }); }) })); -vi.mock("../api/rpcClient", () => ({ rpc: async (_method: string, input: { relative_path: string }) => { +vi.mock("../api/rpcClient", () => ({ rpc: async (_method: string, input: { relative_path: string; resource_scope: { visibility: string } }) => { fixture.calls.push(`connect:${input.relative_path}`); + fixture.scopes.push(input.resource_scope.visibility); if (fixture.failConnect === input.relative_path) throw new Error("synthetic connect failure"); if (fixture.connectGate) await fixture.connectGate; return { value: {}, state: "SUCCEEDED", operation_id: "op:upload" }; @@ -31,7 +33,7 @@ let root: Root; let client: QueryClient; beforeEach(async () => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - fixture.jobs = []; fixture.calls = []; fixture.connectGate = null; fixture.failConnect = ""; setLocale("en"); + fixture.jobs = []; fixture.calls = []; fixture.scopes = []; fixture.connectGate = null; fixture.failConnect = ""; setLocale("en"); container = document.createElement("div"); document.body.append(container); client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); root = createRoot(container); @@ -39,13 +41,16 @@ beforeEach(async () => { }); afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); setLocale("ko"); }); async function tick() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); } -async function select(names: string[]) { +async function selectFiles(files: File[]) { const input = container.querySelector('input[type="file"]')!; - Object.defineProperty(input, "files", { configurable: true, value: names.map(name => new File(["test"], name)) }); + Object.defineProperty(input, "files", { configurable: true, value: files }); await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); - const visibility = container.querySelector(`[aria-label="${t("sources.scopeLabel")}"]`)!; - await act(async () => { visibility.value = "PROJECT_SHARED"; visibility.dispatchEvent(new Event("change", { bubbles: true })); }); } +// On a local workspace nothing else has to be chosen: the usage scope starts as shared within the project. +const select = (names: string[]) => selectFiles(names.map(name => new File(["test"], name))); +const scopeSelect = () => container.querySelector(`[aria-label="${t("sources.scopeLabel")}"]`); +const choose = (node: HTMLSelectElement, value: string) => act(async () => { node.value = value; node.dispatchEvent(new Event("change", { bubbles: true })); }); +const alertText = () => container.querySelector('[role="alert"]')?.textContent ?? ""; function button(label: string) { return [...container.querySelectorAll("button")].find(node => node.textContent === label); } async function start(names: string[]) { await select(names); @@ -137,7 +142,76 @@ it("keeps the existing partial-connect error guidance and cannot cancel a connec expect(fixture.calls).toEqual(["stage:A.md", "stage:B.md", "stage:C.md", "connect:web/A.md", "connect:web/B.md"]); expect(button("Cancel upload")).toBeUndefined(); expect(container.textContent).toContain("synthetic connect failure"); - expect(container.textContent).toContain(t("sources.checkConnected")); + // The title says one thing, and the sentence says what became of the rest of the selection. + expect(alertText()).toContain("The source was not connected"); + expect(alertText()).not.toContain("may be connected"); + expect(alertText()).toContain("1 of the 3 selected files was connected before this one. Check the source list."); +}); + +it("connects a chosen file on a local workspace with no other choice, shared within the project", async () => { + expect(button(t("composer.attach"))!.disabled).toBe(true); + // The scope is said in one line; its control is under the fold. + expect(container.textContent).toContain("Usage scope: Shared within this project"); + expect(scopeSelect()).toBeNull(); + await select(["A.md"]); + expect(button(t("composer.attach"))!.disabled).toBe(false); + await act(async () => { button(t("composer.attach"))!.click(); }); await tick(); + await act(async () => fixture.jobs[0].finish()); await tick(); + expect(fixture.scopes).toEqual(["PROJECT_SHARED"]); +}); + +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"); + await choose(scopeSelect()!, "EXPLICIT_GRANT"); + expect(button("Usage scope: Explicitly permitted tasks only")).toBeDefined(); + await start(["A.md"]); + await act(async () => fixture.jobs[0].finish()); await tick(); + expect(fixture.scopes).toEqual(["EXPLICIT_GRANT"]); +}); + +it("still asks for the scope in the hosted review environment", async () => { + await act(async () => { root.render(); }); + await select(["A.md"]); + expect(scopeSelect()!.value).toBe(""); + expect(button(t("composer.attach"))!.disabled).toBe(true); + await choose(scopeSelect()!, "PROJECT_SHARED"); + expect(button(t("composer.attach"))!.disabled).toBe(false); +}); + +it("offers only the types the server stages in the file dialog", () => { + expect(container.querySelector('input[type="file"]')!.accept).toBe(UPLOAD_ACCEPT); + expect(UPLOAD_ACCEPT.split(",")).toContain(".hwpx"); +}); + +it("refuses a file of another type before sending it, naming the file and what can be uploaded", async () => { + await act(async () => setLocale("ja")); + await start(["setup.exe"]); + expect(fixture.calls).toEqual([]); + expect(alertText()).toContain("資料を接続できませんでした"); + expect(alertText()).toContain("「setup.exe」はアップロードできない形式です。アップロードできる形式:HWPX · PDF · DOCX · XLSX · CSV · JSON · MD · TXT · YAML · HTML。"); + // No sentence written by the server in English, and nothing about other files: there was only this one. + expect(alertText()).not.toMatch(/unsupported research file type/); + expect(alertText()).not.toContain("選択した"); + await act(async () => setLocale("ko")); + expect(alertText()).toContain('"setup.exe" 파일은 올릴 수 없는 형식입니다. 올릴 수 있는 형식: HWPX'); +}); + +it("refuses an empty file before sending it, and says that none of the selection was connected", async () => { + await selectFiles([new File(["test"], "A.md"), new File([], "empty.md")]); + await act(async () => { button(t("composer.attach"))!.click(); }); await tick(); + expect(fixture.calls).toEqual([]); + expect(alertText()).toContain('"empty.md" is empty. Upload a file that has content.'); + expect(alertText()).toContain("None of the 2 selected files was connected."); +}); + +it("says a refusal the server names by code in the screen language", async () => { + await start(["big.pdf"]); + await act(async () => fixture.jobs[0].fail(new UploadRefused("UPLOAD_TOO_LARGE", "big.pdf"))); await tick(); + expect(alertText()).toContain('"big.pdf" is larger than 64 MB. Split it into smaller files.'); + await act(async () => setLocale("zh-Hans")); + expect(alertText()).toContain("“big.pdf”超过 64MB。请拆分成较小的文件后上传。"); }); it("aborts staging on unmount and never connects a late result", async () => { diff --git a/apps/web/src/components/FileConnectionPanel.tsx b/apps/web/src/components/FileConnectionPanel.tsx index 0678c5b0..7709348b 100644 --- a/apps/web/src/components/FileConnectionPanel.tsx +++ b/apps/web/src/components/FileConnectionPanel.tsx @@ -10,12 +10,17 @@ import { rpc } from "../api/rpcClient"; import { eyebrowLabel } from "./statusLabels"; import { authorityOptions, sourceConnectSchema } from "../api/sourcePolicy"; import { parserFailureText } from "../api/parserError"; +import { refusalBeforeUpload, UPLOAD_ACCEPT, UploadRefused } from "../api/uploadTypes"; +import { Disclosure } from "./Disclosure"; /** How the last connect attempt ended. It lives in the query cache, not in this component: the * workspace swaps the whole page out while it reads the project list again after a connect (on * success and on failure), and a message kept in component state went away with the page. */ -type ConnectNotice = { kind: "error"; error: Error } | { kind: "success" } | null; -type CapturedFiles = { files: File[]; authority: string; visibility: string; version: number; projectId: string }; +type ConnectNotice = { kind: "error"; error: Error; connected: number; total: number } | { kind: "success" } | null; +type CapturedFiles = { files: File[]; authority: string; visibility: string; version: number; projectId: string; connected: number }; +type Visibility = "" | "PROJECT_SHARED" | "EXPLICIT_GRANT"; +const scopeLabel = (value: Visibility) => + value === "PROJECT_SHARED" ? t("sources.projectShared") : value === "EXPLICIT_GRANT" ? t("sources.explicitGrants") : t("sources.chooseScope"); export function FileConnectionPanel({ projectId, hosted = false }: { projectId: string; hosted?: boolean }) { useTranslation(); @@ -26,7 +31,9 @@ export function FileConnectionPanel({ projectId, hosted = false }: { projectId: const selectionVersion = useRef(0); const fileInput = useRef(null); const [authority, setAuthority] = useState("UNCLASSIFIED"); - const [visibility, setVisibility] = useState(""); + // On one person's own computer a new source is shared within its project unless that is changed under + // the fold. The hosted review environment still asks, as before. + const [visibility, setVisibility] = useState(hosted ? "" : "PROJECT_SHARED"); const [uploadProgress, setUploadProgress] = useState<{name: string; value: UploadProgress} | null>(null); const [connectingName, setConnectingName] = useState(""); const [canCancel, setCanCancel] = useState(false); @@ -41,6 +48,11 @@ export function FileConnectionPanel({ projectId, hosted = false }: { projectId: const ownsSelection = (captured: CapturedFiles) => mounted.current && captured.version === selectionVersion.current && captured.projectId === currentProject.current; const connect = useMutation({ mutationFn: async (captured: CapturedFiles) => { setNotice(null, captured.projectId); + // A file the server would refuse is refused here, before anything is sent. + for (const file of captured.files) { + const refusal = refusalBeforeUpload(file); + if (refusal) throw refusal; + } const controller = new AbortController(); upload.current = controller; setCanCancel(true); @@ -64,6 +76,7 @@ export function FileConnectionPanel({ projectId, hosted = false }: { projectId: await rpc("project/source/connect", sourceConnectSchema.parse({project_id:captured.projectId, relative_path:staged.relative_path, media_type:staged.media_type, authority:captured.authority, cutoff_state:"ELIGIBLE", security_class:"INTERNAL", resource_scope:{owner_kind:"PROJECT",visibility:captured.visibility}, version_label:`web-${staged.byte_sha256.slice(0,12)}`}), crypto.randomUUID()); + captured.connected += 1; } } finally { if (upload.current === controller) upload.current = null; @@ -79,32 +92,70 @@ export function FileConnectionPanel({ projectId, hosted = false }: { projectId: // Connecting a source raises the project revision; the sidebar list must not keep the old one. client.invalidateQueries({queryKey:["projects"]})]); }, onError: (error, captured) => { - setNotice({ kind: "error", error }, captured.projectId); + setNotice({ kind: "error", error, connected: captured.connected, total: captured.files.length }, captured.projectId); // An earlier file of the batch may have been connected before a later one failed. void client.invalidateQueries({queryKey:["projects"]}); if (ownsSelection(captured)) { setUploadProgress(null); setConnectingName(""); } } }); - const progress = uploadProgress ? uploadProgress.value.phase === "processing" - ? message("fileUploadWaiting", uploadProgress.name) - : uploadProgress.value.total ? message("fileUploadProgress", uploadProgress.name, Math.min(100, Math.floor(100 * uploadProgress.value.loaded / uploadProgress.value.total))) - : message("fileStaging", uploadProgress.name) - : connectingName ? message("fileStaging", connectingName) : ""; + const progress = progressText(uploadProgress, connectingName); return

{eyebrowLabel("AUTHORIZED SOURCES")}

{t("sources.fileTitle")}

{t("sources.preserve")}

{t("sources.fileTypes")}{hosted?t("sources.hostedUpload"):t("sources.localUpload")}

-
{ selectionVersion.current += 1; setNotice(null); setUploadProgress(null); setConnectingName(""); setFiles(Array.from((event.target as HTMLInputElement).files ?? [])); }} /> +
{ selectionVersion.current += 1; setNotice(null); setUploadProgress(null); setConnectingName(""); setFiles(Array.from((event.target as HTMLInputElement).files ?? [])); }} /> - - + {hosted && } + {canCancel && }
+ {!hosted && ( + + +

{t("sources.scopeHelp")}

+
+ )} {files.length > 0 &&

{files.map(file=>file.name).join(" · ")}

}{progress &&

{progress}

} - {notice?.kind === "error" && {connectFailure(notice.error)}{sentenceGap()}{t("sources.checkConnected")}} + {notice?.kind === "error" && ( + + {connectFailure(notice.error)} + {batchOutcome(notice.connected, notice.total)} + + )} {notice?.kind === "success" && {t("sources.connected")}}
; } +/** What is happening to the file right now, in one sentence. */ +function progressText(upload: { name: string; value: UploadProgress } | null, connectingName: string): string { + if (!upload) return connectingName ? message("fileStaging", connectingName) : ""; + if (upload.value.phase === "processing") return message("fileUploadWaiting", upload.name); + if (!upload.value.total) return message("fileStaging", upload.name); + return message("fileUploadProgress", upload.name, Math.min(100, Math.floor(100 * upload.value.loaded / upload.value.total))); +} + +/** Who may use a new source. The hosted review environment starts without a choice and asks for one. */ +function ScopeSelect({ value, onChange, askFirst = false }: { value: Visibility; onChange: (value: Visibility) => void; askFirst?: boolean }) { + const options = [ + ...(askFirst ? [{ value: "", get label() { return t("sources.chooseScope"); } }] : []), + { value: "PROJECT_SHARED", get label() { return t("sources.projectShared"); } }, + { value: "EXPLICIT_GRANT", get label() { return t("sources.explicitGrants"); } }, + ]; + return ( + + ); +} + +/** What became of the rest of the selection. One refused file needs no such sentence. */ +function batchOutcome(connected: number, total: number) { + if (total <= 1) return null; + return sentenceGap() + (connected > 0 ? message("batchSomeConnected", connected, total) : message("batchNoneConnected", total)); +} + /** A refused file reads as a sentence that says what to do; the raw message and code stay in a fold. */ function connectFailure(error: Error) { + // The file's name, the reason and what can be uploaded, in the screen language. + if (error instanceof UploadRefused) return error.message; const parser = parserFailureText(error); if (!parser) return error.message; return <>{parser.text}
{t("sources.technicalDetails")}{error.message} · {parser.code}
; diff --git a/apps/web/src/components/FileConnectionPanelParserError.test.tsx b/apps/web/src/components/FileConnectionPanelParserError.test.tsx index 0d98edee..fb0917cc 100644 --- a/apps/web/src/components/FileConnectionPanelParserError.test.tsx +++ b/apps/web/src/components/FileConnectionPanelParserError.test.tsx @@ -29,8 +29,6 @@ async function connectFile() { const input = container.querySelector('input[type="file"]')!; Object.defineProperty(input, "files", { configurable: true, value: [new File(["x"], "report.txt")] }); await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); - const scope = container.querySelector(`[aria-label="${t("sources.scopeLabel")}"]`)!; - await act(async () => { scope.value = "PROJECT_SHARED"; scope.dispatchEvent(new Event("change", { bubbles: true })); }); await act(async () => { [...container.querySelectorAll("button")].find(item => item.textContent === t("composer.attach"))!.click(); }); await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); } @@ -45,7 +43,9 @@ it("says in words what to do when the text encoding is not confirmed, and folds expect(details.querySelector("summary")!.textContent).toBe("기술 정보"); expect(details.textContent).toContain("source parsing failed [-32030]"); expect(details.textContent).toContain("TEXT_ENCODING_UNCONFIRMED"); - expect(callout().textContent).toContain(t("sources.checkConnected")); + expect(callout().textContent).toContain("자료를 연결하지 못했습니다"); + // One file was chosen, so there is no sentence about the rest of a selection. + expect(callout().textContent).not.toContain("선택한 파일"); }); it("uses the English sentence when the screen is in English", async () => { diff --git a/apps/web/src/components/FirstRunSetup.test.tsx b/apps/web/src/components/FirstRunSetup.test.tsx index 0b197e8e..b74f5235 100644 --- a/apps/web/src/components/FirstRunSetup.test.tsx +++ b/apps/web/src/components/FirstRunSetup.test.tsx @@ -1419,3 +1419,53 @@ it("shows the server's English guidance in Korean and keeps unknown English out expect(text).not.toContain("Some brand new English hint"); }); +it("lets a first-time user go on without a model and reach the workspace", async () => { + fixture.ready = { ready: false, deployment_mode: "LOCAL", model_connected: false, setup: { internet_consent: "UNDECIDED" } }; + await mount(true); + const named = (label: string) => [...container.querySelectorAll("button")].find(button => button.textContent === label) as HTMLButtonElement | undefined; + expect(named("다음")!.disabled).toBe(true); + // What "later" means is said next to it. + expect(container.textContent).toContain("나중에 연결하면 질문을 보낼 때 연결을 요청합니다."); + await act(async () => named("나중에 연결")!.click()); await flush(); + expect(container.textContent).toContain("2 / 2"); + expect(fixture.calls.some(call => call.method === "model/credential/register")).toBe(false); + const deny = [...container.querySelectorAll("button")].find(button => button.textContent?.includes("거부")); + await act(async () => deny!.click()); await flush(); + await flush(); + expect(container.querySelector(".first-run-shell")).toBeNull(); + expect(container.querySelector("main.conversation-workstation")).not.toBeNull(); + // The one thing that was decided is the internet question; no model was registered on the way. + expect(fixture.calls.some(call => call.method === "workspace/setup/update" && call.input.internet_consent === "DENIED")).toBe(true); + expect(fixture.calls.some(call => call.method === "model/credential/register")).toBe(false); +}); + +it("offers 'connect later' only while no model is connected", async () => { + fixture.ready = { ready: false, deployment_mode: "LOCAL", model_connected: true, setup: { internet_consent: "UNDECIDED" } }; + fixture.accounts = [{ provider: "openai", label: "ChatGPT", connected: true, has_key: true, oauth: false, + login_supported: true, login_kind: "codex_isolated_browser", remote_auth_verified: null, available_model_providers: ["openai"], + connection_state: "API_KEY_AVAILABLE", profile_mode: "THOTH_LOCAL_KEY", execution_eligible: true, execution_verified: false }]; + await mount(); + for (let i = 0; i < 12 && ![...container.querySelectorAll("button")].some(button => button.textContent === "다음" && !button.disabled); i++) await flush(); + expect([...container.querySelectorAll("button")].some(button => button.textContent === "나중에 연결")).toBe(false); +}); + +it("tells the three providers apart by a mark of their own, not by a first letter two of them share", async () => { + fixture.ready = { ready: false, deployment_mode: "LOCAL", model_connected: false, setup: { internet_consent: "UNDECIDED" } }; + await mount(); + const marks = [...container.querySelectorAll(".first-run-provider .provider-mark")]; + expect(marks.map(mark => mark.getAttribute("data-provider"))).toEqual(["openai", "anthropic", "xai"]); + expect(marks.every(mark => mark.textContent === "" && mark.getAttribute("aria-hidden") === "true")).toBe(true); +}); + +it("opens this computer's settings from the sidebar when no project is open, and keeps the project's settings tab off", async () => { + fixture.ready = localReady({ ready: true, execution_ready: true, model_connected: true, setup_complete: true, setup: { internet_consent: "DENIED" } }); + await mount(true); + const settingsTab = [...container.querySelectorAll('[role="tab"]')].find(tab => tab.textContent === "설정")!; + expect(settingsTab.getAttribute("aria-disabled")).toBe("true"); + const open = [...container.querySelectorAll(".sidebar-bottom button")].find(button => button.textContent === "이 컴퓨터 설정") as HTMLButtonElement; + await act(async () => open.click()); await flush(); + expect(container.querySelector(".workspace-heading h1")?.textContent).toBe("이 컴퓨터 설정"); + expect(container.textContent).toContain("계정 연결"); + expect(container.textContent).toContain("화면 언어"); + expect(container.querySelector("fieldset.model-settings")).toBeNull(); +}); diff --git a/apps/web/src/components/FirstRunSetup.tsx b/apps/web/src/components/FirstRunSetup.tsx index 312a567b..482352ed 100644 --- a/apps/web/src/components/FirstRunSetup.tsx +++ b/apps/web/src/components/FirstRunSetup.tsx @@ -24,6 +24,24 @@ const toneIntent: Record = { openai: "sk-...", anthropic: "sk-ant-...", xai: "xai-..." }; +/** + * The way on from the model step. With a model: "next". Without one: "connect later", because saved research + * can be read without a model and sending a question asks for one. + */ +function ModelStepFooter({ modelConnected, onNext }: { modelConnected: boolean; onNext: () => void }) { + return ( +
+ {modelConnected ? t("setup.modelReady") : t("setup.modelNeeded")} + + {!modelConnected && } + + +
+ ); +} + export function FirstRunSetup({ onDone }: { onDone: () => void }) { useTranslation(); const client = useQueryClient(); @@ -131,7 +149,7 @@ export function FirstRunSetup({ onDone }: { onDone: () => void }) { return (
- +
diff --git a/apps/web/src/components/ResearchSidePanel.tsx b/apps/web/src/components/ResearchSidePanel.tsx index 2692475f..0b59e17d 100644 --- a/apps/web/src/components/ResearchSidePanel.tsx +++ b/apps/web/src/components/ResearchSidePanel.tsx @@ -36,6 +36,7 @@ export function ResearchSidePanel({ project={project} thread={thread} onClose={onClose} + onOpenRecords={onOpenRecords} /> ); } diff --git a/apps/web/src/components/ResourcesPage.test.tsx b/apps/web/src/components/ResourcesPage.test.tsx new file mode 100644 index 00000000..1720b8ad --- /dev/null +++ b/apps/web/src/components/ResourcesPage.test.tsx @@ -0,0 +1,113 @@ +// @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 { ProjectSummary } from "../types"; +import { ResourcesPage } from "./ProjectSessionPages"; +import type { useCitationGate } from "./SourceCitationGate"; +import type { useProjectSources } from "./useProjectSources"; + +// The sources page shows what is connected first: someone who opens it came to see their sources, not +// a web setting they cannot use yet. + +const fixture = vi.hoisted(() => ({ consent: "UNDECIDED" })); +vi.mock("../api/rpcClient", async importOriginal => ({ + ...(await importOriginal()), + rpc: async (method: string) => { + if (method === "workspace/setup/read") return { state: "SUCCEEDED", operation_id: "", value: { internet_consent: fixture.consent, internet_grant_id: null } }; + if (method === "project/read") return { state: "SUCCEEDED", operation_id: "", value: { revision: 1, policy: { payload: { public_web: { enabled: false, preferred_hosts: [] } } } } }; + throw new Error("unexpected method " + method); + }, +})); + +type SourcesRead = ReturnType["data"]; +const project = { project_id: "p", name: "레이더 지연 분석", revision: 3 } as unknown as ProjectSummary; +const citation = { dialog: null, message: "", ask: vi.fn() } as unknown as ReturnType; +const artifact = (id: string, name: string) => ({ artifact_id: id, project_id: "p", source_uri: "file:///" + name, media_type: "application/pdf", + byte_sha256: "0".repeat(64), authority_state: "OFFICIAL", security_class: "INTERNAL", extraction_coverage: "FULL", cutoff_state: "ELIGIBLE" }); +const sources = (artifacts: ReturnType[]) => ({ value: { + artifacts, bindings: artifacts.map(item => ({ binding_id: "b:" + item.artifact_id, artifact_id: item.artifact_id, state: "ACTIVE" })), source_times: [], +}, state: "SUCCEEDED", operation_id: "op" }) as unknown as SourcesRead; + +let root: Root | undefined; +let container: HTMLDivElement; +let client: QueryClient; +const openConsent = vi.fn(); +async function mount(sourceData: SourcesRead, extra: { sourcesError?: Error; hosted?: boolean } = {}) { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + await act(async () => root!.render( + {}} onOpenConsent={extra.hosted ? undefined : openConsent} /> + )); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); +} +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + client?.clear(); + container?.remove(); + fixture.consent = "UNDECIDED"; + openConsent.mockClear(); +}); + +const position = (selector: string) => { + const all = [...container.querySelectorAll("*")]; + const found = container.querySelector(selector); + expect(found, selector).not.toBeNull(); + return all.indexOf(found!); +}; + +it("puts the connected sources first, then adding a file, then the public web sources", async () => { + await mount(sources([artifact("a", "radar-log.pdf"), artifact("b", "weather.pdf")])); + const order = [position(".connected-sources"), position(".source-connect-panel"), position(".project-web-setup")]; + expect(order).toEqual([...order].sort((left, right) => left - right)); + const list = container.querySelector(".connected-sources")!; + expect([...list.querySelectorAll("article.detail-card h3")].map(item => item.textContent)).toEqual(["radar-log.pdf", "weather.pdf"]); + expect(list.textContent).not.toContain("연결한 자료가 없습니다"); +}); + +it("says that nothing is connected yet and where to add a file, but not while the list is unread or failed", async () => { + await mount(sources([])); + expect(container.querySelector(".connected-sources")!.textContent).toContain("연결한 자료가 없습니다. 아래에서 파일을 올려 연결하세요."); + await act(async () => root!.unmount()); container.remove(); + await mount(undefined); + expect(container.querySelector(".connected-sources")!.textContent).not.toContain("연결한 자료가 없습니다"); + await act(async () => root!.unmount()); container.remove(); + await mount(sources([]), { sourcesError: new Error("자료 목록을 읽지 못했습니다") }); + expect(container.querySelector(".connected-sources")!.textContent).not.toContain("연결한 자료가 없습니다"); + expect(container.querySelector('.connected-sources [role="alert"]')!.textContent).toContain("자료 목록을 읽지 못했습니다"); +}); + +it("leaves the web controls out while the workspace does not allow the internet, and gives one way to the setting", async () => { + await mount(sources([])); + const web = container.querySelector(".project-web-setup")!; + expect(web.textContent).toContain("이 작업 공간에서는 인터넷 사용을 허용하지 않았습니다. 허용하면 여기서 조회할 도메인을 등록할 수 있습니다."); + expect(web.querySelectorAll("input")).toHaveLength(0); + const links = [...web.querySelectorAll("button")]; + expect(links.map(item => item.textContent)).toEqual(["설정에서 허용"]); + await act(async () => { links[0].click(); }); + expect(openConsent).toHaveBeenCalledTimes(1); +}); + +it("shows the web controls once the workspace allows the internet", async () => { + fixture.consent = "ALLOWED"; + await mount(sources([])); + const web = container.querySelector(".project-web-setup")!; + expect(web.querySelector("input[type='checkbox']")).not.toBeNull(); + expect(web.textContent).not.toContain("인터넷 사용을 허용하지 않았습니다"); + expect([...web.querySelectorAll("button")].map(item => item.textContent)).not.toContain("설정에서 허용"); +}); + +it("offers no way to the setting where this screen cannot change it (hosted review)", async () => { + await mount(sources([]), { hosted: true }); + const web = container.querySelector(".project-web-setup")!; + expect(web.textContent).toContain("이 작업 공간에서는 인터넷 사용을 허용하지 않았습니다."); + expect(web.textContent).not.toContain("허용하면 여기서"); + expect(web.querySelectorAll("button")).toHaveLength(0); +}); + diff --git a/apps/web/src/components/WorkspaceTopbar.tsx b/apps/web/src/components/WorkspaceTopbar.tsx new file mode 100644 index 00000000..d21fcfcc --- /dev/null +++ b/apps/web/src/components/WorkspaceTopbar.tsx @@ -0,0 +1,89 @@ +import { Button, Icon, Tag } from "@blueprintjs/core"; +import { message } from "../i18n/formatMessages"; +import { t } from "../i18n/locale"; +import { sentenceGap } from "../i18n/sentences"; +import { NAVIGATION_ID, type NavigationDrawer } from "./useNavigationDrawer"; + +export type ServerHealth = "ok" | "failed" | "checking"; + +const healthLabel = (health: ServerHealth) => + health === "ok" ? t("workspace.serverConnected") : health === "failed" ? t("workspace.serverFailed") : t("workspace.checkingConnection"); +const healthIntent = (health: ServerHealth) => (health === "ok" ? "success" : health === "failed" ? "danger" : "none"); + +/** + * The bar above the page: where you are, and what needs attention. Every item stays on one line. When the + * window is narrow the names give way first (with the whole name in the tooltip) and the stylesheet folds + * the tags down to their icons, so nothing is stacked one letter per line. + */ +export function WorkspaceTopbar({ drawer, testMode, projectLabel, threadLabel, cutoff, health, setupNeeded, locationUnsaved, missingSelection, onOpenSettings, onRefresh }: { + drawer: NavigationDrawer; + testMode: boolean; + projectLabel: string; + threadLabel: string | null; + cutoff: string | null; + health: ServerHealth; + setupNeeded: boolean; + locationUnsaved: boolean; + missingSelection: "project" | "thread" | null; + onOpenSettings: () => void; + onRefresh: () => void; +}) { + const cutoffText = cutoff ? message("cutoff", cutoff) : null; + const missingText = missingSelection ? message("missingSelection", missingSelection) : null; + return ( +
+ + )} + {locationUnsaved && ( + + {t("workspace.locationUnsaved")} + + )} + {missingText && ( + + {missingText} + + )} +
+ ); +} + diff --git a/apps/web/src/components/history/HistoryChangeSummary.test.tsx b/apps/web/src/components/history/HistoryChangeSummary.test.tsx index b0ee6483..6eeb2436 100644 --- a/apps/web/src/components/history/HistoryChangeSummary.test.tsx +++ b/apps/web/src/components/history/HistoryChangeSummary.test.tsx @@ -31,7 +31,7 @@ it("opens the before and after text by itself when a number, negation, status, c const toggle = node.querySelector("button[aria-expanded]") as HTMLButtonElement; expect(toggle.getAttribute("aria-expanded")).toBe("true"); expect(node.textContent).toContain("지연이 12 ms 늘어난다"); expect(node.textContent).toContain("지연이 20 ms 늘어난다"); - expect(node.textContent).toContain("숫자·부정·상태·인용·권위·시점"); + expect(node.textContent).toContain("숫자·부정·상태·인용·자료 성격·시점"); }); it("reads a human estimate as one Korean line and keeps unnamed fields as a count with the raw form folded under technical info", async () => { diff --git a/apps/web/src/components/history/HistoryTimeline.tsx b/apps/web/src/components/history/HistoryTimeline.tsx index 69a963bb..cfda9051 100644 --- a/apps/web/src/components/history/HistoryTimeline.tsx +++ b/apps/web/src/components/history/HistoryTimeline.tsx @@ -24,7 +24,7 @@ export function HistoryTimeline({ pages, selected, loadingMore, onSelect, onMore const partial = pages.some(page => page.coverage.association !== "EXACT" || page.coverage.scan !== "COMPLETE_PAGE"); return

{t("timeline.flow")}

{t("timeline.newest")}
- {partial &&

{t("timeline.coverage")}

} + {partial &&

{t("timeline.coverage")}

} {items.length === 0 &&

{onlyCheckpoints ? t("timeline.hasCheckpoints") : last?.nextCursor || partial ? t("timeline.emptyPage") : t("timeline.emptyHistory")}

diff --git a/apps/web/src/components/history/ProjectReviewList.tsx b/apps/web/src/components/history/ProjectReviewList.tsx index 4e9f55f0..1cc485b1 100644 --- a/apps/web/src/components/history/ProjectReviewList.tsx +++ b/apps/web/src/components/history/ProjectReviewList.tsx @@ -19,16 +19,33 @@ function selectionOf(item: ProjectReviewItem): HistorySelection { resultDigest: item.result_revision_digest ?? undefined, title: item.title }; } -export function ProjectReviewList({ projectId, onSelect }: { projectId: string; onSelect: (selection: HistorySelection) => void }) { - const query = useInfiniteQuery({ queryKey: ["project-review-list", projectId], initialPageParam: null as string | null, retry: false, gcTime: 0, +/** The answers of a project that need review. Both pieces below read it; the request is made once. */ +function useProjectReview(projectId: string, enabled = true) { + const query = useInfiniteQuery({ queryKey: ["project-review-list", projectId], initialPageParam: null as string | null, retry: false, gcTime: 0, enabled, queryFn: ({ pageParam, signal }) => readProjectReviewList(projectId, pageParam, signal), getNextPageParam: page => page.next_cursor ?? undefined }); const blocked = useReadAccessProtection(["project-review-list", projectId], query.error); const pages = blocked ? [] : query.data?.pages ?? []; const items = pages.flatMap(page => page.items); + return { query, blocked, pages, items, nothingToReview: !query.isPending && !query.error && items.length === 0 }; +} + +/** + * Nothing to review is one line next to the screen's controls, not a card above the records. Where the + * screen shows no review list (show is false) the place stays empty and nothing is read. + */ +export function ProjectReviewEmptyNote({ projectId, show }: { projectId: string; show: boolean }) { + const { nothingToReview } = useProjectReview(projectId, show); + return show && nothingToReview ?

{t("reviewList.empty")}

: ; +} + +export function ProjectReviewList({ projectId, onSelect }: { projectId: string; onSelect: (selection: HistorySelection) => void }) { + const { query, blocked, pages, items, nothingToReview } = useProjectReview(projectId); if ((query.error || blocked) && pages.length === 0) return {t("reviewList.failed")}; + // The empty state is ProjectReviewEmptyNote. The screen's own "read again" reads this list too. + if (nothingToReview) return null; return
-

{t("followup.reviewNeeded")}

{t("reviewList.notice")}

- {query.isPending ?

{t("reviewList.loading")}

: items.length === 0 ?

{t("reviewList.empty")}

+

{t("followup.reviewNeeded")}

{t("reviewList.notice")}

+ {query.isPending ?

{t("reviewList.loading")}

:
{items.map(item =>
{priorityLabel(item.priority)}{currentnessLabel(item.currentness)}

{item.title}

diff --git a/apps/web/src/components/history/ResearchHistoryWorkspace.test.tsx b/apps/web/src/components/history/ResearchHistoryWorkspace.test.tsx index d7ac2a28..663e8e24 100644 --- a/apps/web/src/components/history/ResearchHistoryWorkspace.test.tsx +++ b/apps/web/src/components/history/ResearchHistoryWorkspace.test.tsx @@ -257,7 +257,8 @@ it("discards a late comparison after ordinary selection or actor scope changes", actorChanged = true; await act(async () => { await client.invalidateQueries({ queryKey: ["research-history", ids.project] }); }); await flush(); expect(container.querySelector(".decision-delta-view")).toBeNull(); - expect(container.textContent).toContain("비교 전: 선택 전"); + // The pair is dropped with it: the comparison section is shown only while a side is chosen. + expect(container.querySelector(".history-decision-comparison")).toBeNull(); }); it("discards a late detail response after a different record was selected", async () => { @@ -408,3 +409,83 @@ it("reopens a lost response using its original key and verifies the new head", a expect(applies[0].input).toEqual(applies[1].input); expect(fixture.appliedCount).toBe(1); }); + +const withoutReviewItems = (fetcher: typeof fetch): typeof fetch => async (url, init) => { + const response = await fetcher(url, init); + if (JSON.parse(String(init?.body)).method !== "project/review/list") return response; + const envelope = await response.json(); envelope.result.value.items = []; + return new Response(JSON.stringify(envelope)); +}; +const position = (selector: string) => { + const found = container.querySelector(selector); + if (!found) throw new Error("not on screen: " + selector); + return [...container.querySelectorAll("*")].indexOf(found); +}; + +it("starts with the records: nothing to review is one line and no empty comparison card sits above them", async () => { + const fixture = createHistoryFixtureTransport(); await mount(withoutReviewItems(fixture.fetch)); + // One line, without the card's heading and explanation. + expect(container.querySelector(".project-review-empty")?.textContent).toBe("현재 프로젝트에 표시할 검토 필요 답변이 없습니다."); + expect(container.querySelector(".project-review-list")).toBeNull(); + expect(container.textContent).not.toContain("읽지 않음 표시, 담당자 배정"); + expect(container.querySelector(".history-decision-comparison")).toBeNull(); + expect(position(".project-review-empty")).toBeLessThan(position(".history-layout")); + // One button reads the screen again. + expect([...container.querySelectorAll("button")].filter(item => item.textContent === "새로 읽기")).toHaveLength(1); + // The comparison appears above the records once a side is chosen there. + await choose("첫 질문에 대한 답변", "비교 전"); + expect(container.querySelector(".history-decision-comparison")?.textContent).toContain("비교 후: 선택 전"); + expect(position(".history-decision-comparison")).toBeLessThan(position(".history-layout")); + await click("비교 해제"); + expect(container.querySelector(".history-decision-comparison")).toBeNull(); +}); + +it("shows the answers that need review as a card, and reads them again with the one reload button", async () => { + const fixture = createHistoryFixtureTransport(); await mount(fixture.fetch); + expect(container.querySelector(".project-review-list h2")?.textContent).toBe("검토 필요"); + expect([...container.querySelectorAll("button")].filter(item => item.textContent === "새로 읽기")).toHaveLength(1); + const reads = () => fixture.calls.filter(call => call.method === "project/review/list").length; + const before = reads(); + await click("새로 읽기"); + expect(reads()).toBe(before + 1); +}); + +it("does not colour the note about how much of the history is covered as a warning", async () => { + const fixture = createHistoryFixtureTransport(); + const partial: typeof fetch = async (url, init) => { + const response = await fixture.fetch(url, init); + if (JSON.parse(String(init?.body)).method !== "revision/timeline/read") return response; + const envelope = await response.json(); envelope.result.value.coverage = { ...envelope.result.value.coverage, association: "PARTIAL" }; + return new Response(JSON.stringify(envelope)); + }; + await mount(partial); + const note = container.querySelector(".history-timeline [role='status']")!; + expect(note.textContent).toContain("현재 읽을 수 있는 기록입니다."); + expect(note.className).toBe("history-coverage-note"); +}); + +it("shows only the answer's own record in the panel next to it, with one way to the whole history", async () => { + const fixture = createHistoryFixtureTransport(); const openFull = vi.fn(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.stubGlobal("fetch", fixture.fetch); + container = document.createElement("div"); document.body.append(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const selection = { kind: "result" as const, scope: { projectId: ids.project, threadId: ids.thread, requestDigest: ids.request2 }, resultDigest: ids.result2, title: "추가 질문에 대한 답변" }; + await act(async () => root!.render( + + )); + await flush(); + expect(container.querySelector(".history-detail")?.textContent).toContain("두 번째 답변:"); + // No second title, no list of every record, no comparison picker, no reload bar: those belong to the history tab. + expect(container.querySelector("h1")).toBeNull(); + expect(container.textContent).not.toContain("연구 이력연구 이력"); + expect(container.querySelector(".history-timeline")).toBeNull(); + expect(container.querySelector(".history-decision-comparison")).toBeNull(); + expect(container.querySelector(".history-toolbar")).toBeNull(); + expect(container.querySelector(".project-review-list, .project-review-empty")).toBeNull(); + const links = [...container.querySelectorAll("button")].filter(item => item.textContent === "연구 이력 열기"); + expect(links).toHaveLength(1); + await act(async () => links[0].click()); + expect(openFull).toHaveBeenCalledTimes(1); +}); diff --git a/apps/web/src/components/history/ResearchHistoryWorkspace.tsx b/apps/web/src/components/history/ResearchHistoryWorkspace.tsx index 91c192f9..c031aa49 100644 --- a/apps/web/src/components/history/ResearchHistoryWorkspace.tsx +++ b/apps/web/src/components/history/ResearchHistoryWorkspace.tsx @@ -9,10 +9,42 @@ import { HistoryDetail } from "./HistoryDetail"; import { HistoryTimeline } from "./HistoryTimeline"; import { Disclosure } from "../Disclosure"; import { eyebrowLabel } from "../statusLabels"; -import { ProjectReviewList } from "./ProjectReviewList"; +import { ProjectReviewEmptyNote, ProjectReviewList } from "./ProjectReviewList"; import { DecisionDeltaComparison } from "./DecisionDeltaComparison"; import { useReadAccessProtection } from "../useReadAccessProtection"; +/** The history pages of one scope, read page by page; an access refusal empties them. */ +function useHistoryPages(scope: HistoryScope, kinds?: HistoryKind[]) { + const queryKey = ["research-history", scope.projectId, scope.threadId ?? null, scope.requestDigest ?? null, kinds?.join(",") ?? "all"]; + const query = useInfiniteQuery({ queryKey, initialPageParam: null as string | null, retry: false, gcTime: 0, + queryFn: ({ pageParam, signal }) => readHistoryPage(scope, pageParam, signal, kinds), getNextPageParam: page => page.nextCursor ?? undefined }); + const blocked = useReadAccessProtection(queryKey, query.error); + const pages = blocked ? [] : query.data?.pages ?? []; + return { queryKey, query, blocked, pages, actorScope: pages[0]?.actorScope ?? null }; +} + +function HistoryReadFailure({ error, blocked, onReload }: { error: Error | null; blocked: boolean; onReload: () => void }) { + if (!error && !blocked) return null; + const invalidCursor = error instanceof RpcError && error.details.reason_code === "HISTORY_CURSOR_INVALID"; + return {invalidCursor ? t("historyWorkspace.cursorExpired") : t("historyWorkspace.failed")}

{error?.message}

; +} + +/** + * The panel next to an answer: that answer's record only, with one way to the whole history. The panel is + * 400px wide; the whole history screen in it read ten letters to a line. + */ +function AnswerRecord({ scope, selection, onUseQuestion, onOpenFull }: { + scope: HistoryScope; selection: HistorySelection; onUseQuestion?: (question: string) => void; onOpenFull?: () => void; +}) { + const client = useQueryClient(); + const { queryKey, query, blocked, actorScope } = useHistoryPages(scope); + return <> + void client.resetQueries({ queryKey, exact: true })} /> + {!blocked && } + {onOpenFull && } + ; +} + function ScopedHistory({ scope, initialSelection, onUseQuestion, kinds }: { scope: HistoryScope; initialSelection?: HistorySelection; onUseQuestion?: (question: string) => void; kinds?: HistoryKind[]; }) { @@ -24,12 +56,7 @@ function ScopedHistory({ scope, initialSelection, onUseQuestion, kinds }: { const [historyCheckEpoch, setHistoryCheckEpoch] = useState(0); const previousHistory = useRef({ updatedAt: 0, pageCount: 0 }); const [showCheckpoints, setShowCheckpoints] = useState(false); - const queryKey = ["research-history", scope.projectId, scope.threadId ?? null, scope.requestDigest ?? null, kinds?.join(",") ?? "all"]; - const query = useInfiniteQuery({ queryKey, initialPageParam: null as string | null, retry: false, gcTime: 0, - queryFn: ({ pageParam, signal }) => readHistoryPage(scope, pageParam, signal, kinds), getNextPageParam: page => page.nextCursor ?? undefined }); - const blocked = useReadAccessProtection(queryKey, query.error); - const pages = blocked ? [] : query.data?.pages ?? []; - const actorScope = pages[0]?.actorScope ?? null; + const { queryKey, query, blocked, pages, actorScope } = useHistoryPages(scope, kinds); const contextChanged = pages.some(page => page.actorScope !== actorScope); const historyError = Boolean(query.error); useEffect(() => { setBefore(null); setAfter(null); setComparisonActorScope(null); }, [actorScope, contextChanged, historyError]); @@ -40,22 +67,29 @@ function ScopedHistory({ scope, initialSelection, onUseQuestion, kinds }: { } previousHistory.current = { updatedAt: query.dataUpdatedAt, pageCount: pages.length }; }, [query.dataUpdatedAt, pages.length]); - const invalidCursor = query.error instanceof RpcError && query.error.details.reason_code === "HISTORY_CURSOR_INVALID"; - const refresh = () => { setSelection(null); setBefore(null); setAfter(null); setComparisonActorScope(null); void client.resetQueries({ queryKey, exact: true }); }; + const refresh = () => { + setSelection(null); setBefore(null); setAfter(null); setComparisonActorScope(null); + void client.resetQueries({ queryKey, exact: true }); + // One "read again" for the whole screen: the answers that need review are read again with the records. + void client.resetQueries({ queryKey: ["project-review-list", scope.projectId], exact: true }); + }; const chooseComparison = (side: "before" | "after", next: HistorySelection) => { if (!actorScope || next.kind !== "result" || !next.resultDigest) return; setComparisonActorScope(actorScope); if (side === "before") setBefore(next); else setAfter(next); setSelection(next); }; + const comparing = Boolean(before || after); + const showReview = !initialSelection && !kinds; return <> -

{t("historyWorkspace.intro")}

setShowCheckpoints(event.currentTarget.checked)}/>
- {(query.error || blocked) && {invalidCursor ? t("historyWorkspace.cursorExpired") : t("historyWorkspace.failed")}

{query.error?.message}

} +
setShowCheckpoints(event.currentTarget.checked)}/>
+ {contextChanged && {t("historyWorkspace.scopeChanged")}} - {!initialSelection && !kinds && { setSelection(next); setBefore(null); setAfter(null); }} />} - {!contextChanged && !query.error && pages.length > 0 &&
+ {showReview && { setSelection(next); setBefore(null); setAfter(null); }} />} + {/* Shown once a side has been chosen in the list. Before that it was an empty card above the records. */} + {comparing && !contextChanged && !query.error && pages.length > 0 &&

{t("historyWorkspace.compare")}

{t("historyWorkspace.choosePair")}

- {(before || after) && }
+
{t("labels.before")}{" "}{comparisonActorScope === actorScope && before?.kind === "result" ? before.title ?? t("historyWorkspace.chosen") : t("historyWorkspace.notChosen")}{t("labels.after")}{" "}{comparisonActorScope === actorScope && after?.kind === "result" ? after.title ?? t("historyWorkspace.chosen") : t("historyWorkspace.notChosen")}
{comparisonActorScope === actorScope && before && after && } @@ -72,18 +106,23 @@ function ScopedHistory({ scope, initialSelection, onUseQuestion, kinds }: { ; } -export function ResearchHistoryWorkspace({ projectId, threadId, requestDigest, initialSelection, onUseQuestion, compact = false, kinds }: { +export function ResearchHistoryWorkspace({ projectId, threadId, requestDigest, initialSelection, onUseQuestion, compact = false, kinds, onOpenFull }: { projectId: string; threadId?: string; initialSelection?: HistorySelection; onUseQuestion?: (question: string) => void; compact?: boolean; kinds?: HistoryKind[]; requestDigest?: string; + /** compact: the panel next to an answer, which shows that answer's record only. onOpenFull opens the history tab from there. */ + onOpenFull?: () => void; }) { const [allProject, setAllProject] = useState(!threadId); const scope: HistoryScope = initialSelection?.scope ?? { projectId, ...(!allProject && threadId ? { threadId } : {}), ...(requestDigest ? { requestDigest } : {}) }; if (scope.projectId !== projectId) return {t("historyWorkspace.projectMismatch")}; return
{import.meta.env.VITE_THOTH_TEST_MODE === "true" && {t("historyWorkspace.testMode")}} -

{eyebrowLabel("RESEARCH HISTORY")}

{kinds?.length === 1 && kinds[0] === "MEMORY" ? t("result.memory") : t("workspace.history")}

{t("historyWorkspace.preserve")}

+ {/* The panel has its own title ("변경 이력"); a second, different one inside it read as another feature. */} + {!compact &&

{eyebrowLabel("RESEARCH HISTORY")}

{kinds?.length === 1 && kinds[0] === "MEMORY" ? t("result.memory") : t("workspace.history")}

{t("historyWorkspace.preserve")}

{!initialSelection && !requestDigest && threadId &&
} -
- +
} + {compact && initialSelection + ? + : }
; } diff --git a/apps/web/src/components/useNavigationDrawer.ts b/apps/web/src/components/useNavigationDrawer.ts new file mode 100644 index 00000000..f893979e --- /dev/null +++ b/apps/web/src/components/useNavigationDrawer.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef, useState, type FocusEvent, type KeyboardEvent } from "react"; + +export const NAVIGATION_ID = "project-navigation"; + +/** + * The project list as a drawer. In a narrow window the stylesheet takes the list out of the page and + * shows it only while it is open; in a wide window the list is always there and this state changes nothing. + */ +export function useNavigationDrawer() { + const [open, setOpen] = useState(false); + const button = useRef(null); + const rail = useRef(null); + const wasOpen = useRef(false); + const giveFocusBack = useRef(true); + + // Opening moves the focus into the list. Closing gives it back to the button, unless the focus has already moved on. + useEffect(() => { + if (open) rail.current?.querySelector("button:not([disabled]), input")?.focus(); + else if (wasOpen.current && giveFocusBack.current) button.current?.focus(); + wasOpen.current = open; + }, [open]); + + const close = useCallback((focusBack = true) => { + giveFocusBack.current = focusBack; + setOpen(false); + }, []); + const toggle = useCallback(() => { + giveFocusBack.current = true; + setOpen(value => !value); + }, []); + + const onKeyDown = (event: KeyboardEvent) => { + // A menu or a dialog opened from the list closes itself on Escape and does not pass the key on. + if (event.key !== "Escape" || !open) return; + event.stopPropagation(); + close(); + }; + const onBlur = (event: FocusEvent) => { + const list = rail.current; + const next = event.relatedTarget; + if (!open || !list || !(next instanceof Node) || list.contains(next)) return; + // Focus that went on into the page leaves the list. A menu or a dialog drawn outside the page does not. + if (list.parentElement?.contains(next)) close(false); + }; + + /** A choice made in the list (a project, a conversation, the settings): the list closes after it. */ + const closing = (action: (...args: A) => void) => (...args: A) => { + action(...args); + close(); + }; + + return { + open, + toggle, + close, + closing, + button, + railProps: { id: NAVIGATION_ID, ref: rail, "data-open": open, onKeyDown, onBlur }, + }; +} + +export type NavigationDrawer = ReturnType; + diff --git a/apps/web/src/components/workspaceSelection.ts b/apps/web/src/components/workspaceSelection.ts index 8527efc9..bd46d2af 100644 --- a/apps/web/src/components/workspaceSelection.ts +++ b/apps/web/src/components/workspaceSelection.ts @@ -7,7 +7,10 @@ import { readDraft, writeDraft } from "../api/conversation"; import { fillQuestion, threadForRow, type RowInvestigation } from "./traceInvestigation"; import type { WorkThread } from "../types"; -export type Page = "research" | "resources" | "records" | "trace" | "raw-records" | "settings" | "capabilities"; +/** "settings" is the open project's settings; "computer" is this computer's (accounts, language, internet, local search parts). */ +export type Page = "research" | "resources" | "records" | "trace" | "raw-records" | "settings" | "computer" | "capabilities"; +/** Which settings screen a link opens. The hosted review environment has one screen for both. */ +export const settingsPageFor = (hosted: boolean, target?: "consent" | "project"): Page => (hosted || target === "project" ? "settings" : "computer"); export type TraceRowRef = { kind: string; id: string }; /** The trace row a question was started from: held here until it is sent, so the next request carries it. */ export type RowOrigin = { threadId: string; origin: PendingOrigin }; diff --git a/apps/web/src/i18n/activityMessages.ts b/apps/web/src/i18n/activityMessages.ts index f9b1fb6e..8344253a 100644 --- a/apps/web/src/i18n/activityMessages.ts +++ b/apps/web/src/i18n/activityMessages.ts @@ -372,7 +372,7 @@ export const activityMessages = { "en": "Citation or evidence changed" }, "changeSummary.authority": { - "ko": "출처 권위 변경", + "ko": "자료 성격 변경", "en": "Source authority changed" }, "changeSummary.time": { @@ -384,7 +384,7 @@ export const activityMessages = { "en": "Changes in this version" }, "changeSummary.notice": { - "ko": "숫자·부정·상태·인용·권위·시점이 바뀐 항목은 원문을 함께 보여 줍니다.", + "ko": "숫자·부정·상태·인용·자료 성격·시점이 바뀐 항목은 원문을 함께 보여 줍니다.", "en": "Original text is shown when numbers, negation, states, citations, authority, or timing changed." }, "changeSummary.original": { @@ -476,11 +476,11 @@ export const activityMessages = { "en": "Search passages" }, "evidenceRail.authorityFilter": { - "ko": "근거 권위 필터", + "ko": "자료 성격으로 거르기", "en": "Evidence authority filter" }, "evidenceRail.all": { - "ko": "모든 권위", + "ko": "모든 자료 성격", "en": "All authority levels" }, "evidenceRail.official": { diff --git a/apps/web/src/i18n/advancedMessages.ts b/apps/web/src/i18n/advancedMessages.ts index 05a271f1..6cd9660f 100644 --- a/apps/web/src/i18n/advancedMessages.ts +++ b/apps/web/src/i18n/advancedMessages.ts @@ -20,7 +20,7 @@ export const advancedMessages = { "en": "Search scope · acquisition stage · stop reason" }, "advanced.evidenceHint": { - "ko": "원문 위치 · 지지 · 권위 · 시점", + "ko": "원문 위치 · 지지 · 자료 성격 · 시점", "en": "Source locations · support · authority · timing" }, "advanced.object": { diff --git a/apps/web/src/i18n/commonStateMessages.ts b/apps/web/src/i18n/commonStateMessages.ts index 30357c38..999cdd05 100644 --- a/apps/web/src/i18n/commonStateMessages.ts +++ b/apps/web/src/i18n/commonStateMessages.ts @@ -28,7 +28,7 @@ export const commonStateMessages = { "en": "Authoritative source" }, "state.authorityNeeded": { - "ko": "출처 권위 확인 필요", + "ko": "자료 성격 확인 필요", "en": "Source authority needs checking" }, "state.short": { diff --git a/apps/web/src/i18n/formatMessages.ts b/apps/web/src/i18n/formatMessages.ts index d5f83ec3..2a7cf94e 100644 --- a/apps/web/src/i18n/formatMessages.ts +++ b/apps/web/src/i18n/formatMessages.ts @@ -34,7 +34,6 @@ const formats = { }, cutoff: { ko: (value: string | null) => `기준시점 ${value}`, en: (value: string | null) => `Cutoff ${value}`, "zh-Hans": (value: string | null)=>`截止时间 ${value}`, "zh-Hant": (value: string | null)=>`截止時間 ${value}`, ja: (value: string | null)=>`基準時点 ${value}` }, deleteProjectTitle: { ko: (name: string) => `프로젝트 "${name}" 영구 삭제`, en: (name: string) => `Delete project "${name}" permanently`, "zh-Hans": (name: string)=>`永久删除项目“${name}”`, "zh-Hant": (name: string)=>`永久刪除專案“${name}”`, ja: (name: string)=>`プロジェクト「${name}」を完全に削除` }, - deleteProjectLabel: { ko: (name: string) => `${name} 영구 삭제`, en: (name: string) => `Delete ${name} permanently`, "zh-Hans": (name: string)=>`永久删除 ${name}`, "zh-Hant": (name: string)=>`永久刪除 ${name}`, ja: (name: string)=>`${name} を完全に削除` }, deleteReferenced: { ko: (ids: string) => `다른 프로젝트가 이 프로젝트를 참조하고 있어 삭제할 수 없습니다. 참조하는 프로젝트: ${ids}`, en: (ids: string) => `Other projects reference this project, so it cannot be deleted. Referencing projects: ${ids}`, "zh-Hans": (ids: string)=>`其他项目引用了此项目,因此无法删除。引用它的项目:${ids}`, "zh-Hant": (ids: string)=>`其他專案引用了此專案,因此無法刪除。引用它的專案:${ids}`, ja: (ids: string)=>`ほかのプロジェクトがこのプロジェクトを参照しているため、削除できません。参照しているプロジェクト:${ids}`, diff --git a/apps/web/src/i18n/ja/researchPanelsMessagesJa.ts b/apps/web/src/i18n/ja/researchPanelsMessagesJa.ts index fb964fac..e90d0c7d 100644 --- a/apps/web/src/i18n/ja/researchPanelsMessagesJa.ts +++ b/apps/web/src/i18n/ja/researchPanelsMessagesJa.ts @@ -75,7 +75,6 @@ export const researchPanelsJa: ColumnOf = { "memory.original": "元のバージョン", "memory.fingerprint": "記録のフィンガープリント", "memory.notice": "記憶の呼び出しは調査の質問ごとに決まり、この画面では決まりません。訂正は新しいバージョンの案を作り、元の記憶は履歴に残ります。", - "historyWorkspace.intro": "質問、回答、判定がどう変わったかを確認します。", "historyWorkspace.checkpoints": "チェックポイントを含める", "historyWorkspace.reload": "再読み込み", "historyWorkspace.cursorExpired": "このページはこれ以上続けられません。再読み込みして、最初のページから始めてください。", diff --git a/apps/web/src/i18n/ja/setupMessagesJa.ts b/apps/web/src/i18n/ja/setupMessagesJa.ts index 97acc5ef..7df97be5 100644 --- a/apps/web/src/i18n/ja/setupMessagesJa.ts +++ b/apps/web/src/i18n/ja/setupMessagesJa.ts @@ -31,7 +31,8 @@ export const setupJa: ColumnOf = { "setup.methodsFailed": "接続方法を確認できませんでした。接続状態を更新してください。", "setup.refreshConnection": "接続状態を更新", "setup.modelReady": "接続しました。次に、ウェブへのアクセスを許可するかを選んでください。", - "setup.modelNeeded": "続けるには、モデルを1つ接続してください。", + "setup.modelNeeded": "モデルを1つ接続すると、すぐに質問できます。あとで接続する場合は、質問を送るときに接続を求めます。", + "setup.connectLater": "あとで接続", "setup.next": "次へ", "setup.webQuestion": "このワークスペースで公開ウェブへのアクセスを許可しますか?", "setup.hostedDisclosure": "質問と分析に使う資料は、運営者の OpenAI API に送信されます。ウェブの同意は、モデル呼び出しとは別に、資料の取得を対象とします。", diff --git a/apps/web/src/i18n/ja/sourceMessagesJa.ts b/apps/web/src/i18n/ja/sourceMessagesJa.ts index 02762bf4..703fb285 100644 --- a/apps/web/src/i18n/ja/sourceMessagesJa.ts +++ b/apps/web/src/i18n/ja/sourceMessagesJa.ts @@ -19,8 +19,8 @@ export const sourceJa: ColumnOf = { "sources.chooseScope": "利用範囲を選択", "sources.projectShared": "このプロジェクト内で共有", "sources.explicitGrants": "明示的に許可されたタスクのみ", - "sources.connectFailed": "資料の接続に失敗 / 一部のファイルは接続された可能性があります", - "sources.checkConnected": "すでに接続されているファイルは、資料の一覧で確認してください。", + "sources.connectFailed": "資料を接続できませんでした", + "sources.scopeHelp": "「このプロジェクト内で共有」にすると、このプロジェクトのすべての作業がこの資料を根拠として使えます。「明示的に許可した作業のみ」にすると、個別に許可した作業だけが使えます。", "sources.connected": "資料を接続しました。新しい質問または指示で分析を始めてください。", "sources.unclassified": "権威性は未分類", "sources.informal": "非公式の参考資料", @@ -39,6 +39,8 @@ export const sourceJa: ColumnOf = { "web.consentScope": "許可:接続済みのファイルのあと、調査はプロジェクトに登録されたサイトだけを使えます。検索は始まりません。拒否:ウェブからの取得は無効になり、モデル呼び出しは続きます。", "web.permittedSources": "許可された公開ウェブの資料", "web.registerFirst": "先に、許可するドメインを登録してください。保存や有効化をしても、検索は始まりません。", + "web.allowInSettings": "設定で許可", + "web.deniedNext": "許可すると、ここで参照するドメインを登録できます。", "web.workspaceDenied": "このワークスペースではインターネットへのアクセスが許可されていません。", "web.domainLabel": "許可する公開ウェブのドメイン", "web.domainExample": "例:arxiv.org", diff --git a/apps/web/src/i18n/ja/workspaceDetailMessagesJa.ts b/apps/web/src/i18n/ja/workspaceDetailMessagesJa.ts index 0cbedb1a..4e2320ae 100644 --- a/apps/web/src/i18n/ja/workspaceDetailMessagesJa.ts +++ b/apps/web/src/i18n/ja/workspaceDetailMessagesJa.ts @@ -2,7 +2,6 @@ import type { ColumnOf } from "../withColumns"; import type { workspaceDetailMessages } from "../workspaceDetailMessages"; export const workspaceDetailJa: ColumnOf = { - "workspace.questionEvidence": "質問から根拠へ、根拠から次の行動へ。", "workspace.continueHeading": "調査を続けるか、新しい問題を始めてください。", "workspace.authorizedRead": "アクセス権限の範囲内で、保存済みの調査を引き続き読めます。", "workspace.readOnlyHold": "権限の範囲内で、保存済みの調査は引き続き利用できます。新しいモデルの実行と同意の変更は保留されています。", diff --git a/apps/web/src/i18n/ja/workspaceMessagesJa.ts b/apps/web/src/i18n/ja/workspaceMessagesJa.ts index bdb46929..9270e2ca 100644 --- a/apps/web/src/i18n/ja/workspaceMessagesJa.ts +++ b/apps/web/src/i18n/ja/workspaceMessagesJa.ts @@ -27,13 +27,6 @@ export const workspaceJa: ColumnOf = { "workspace.profile": "ドメインプロファイル", "workspace.createNotice": "プロジェクトを作成したら、質問をして、関連する資料を接続してください。", "workspace.create": "プロジェクトを作成", - "workspace.sourcesFirst": "まず接続した資料から", - "workspace.sourceNotice": "元の位置、資料、基準時点をあわせて確認します。", - "workspace.alternatives": "反証の根拠と別の説明", - "workspace.alternativesNotice": "仮説や行動を比較しながら、不確かさと部分的な結果を保持します。", - "workspace.historyTitle": "続いていく調査の記録", - "workspace.historyNotice": "結果、記憶、改訂、復元の根拠をたどれます。", - "workspace.chooseOrCreate": "左のプロジェクトを続けるか、新しい調査の文脈を作成してください。", "workspace.continue": "調査を続ける", "workspace.newQuestion": "新しい質問", "workspace.testModeNotice": "検証モード:この画面は、固定の応答と制御されたデータを使います。", @@ -43,13 +36,23 @@ export const workspaceJa: ColumnOf = { "workspace.resources": "資料と接続", "workspace.resourcesNotice": "調査用のファイルと、許可された公開ウェブの資料を管理します。", "workspace.backConversation": "会話に戻る", + "workspace.noConnectedSources": "接続した資料はまだありません。下でファイルをアップロードして接続してください。", "workspace.connectedSources": "接続済みの資料", "workspace.cite": "引用として使う", "workspace.sourceDetails": "資料と抽出の状態", "workspace.operations": "ワークスペース情報", + "settings.thisProject": "このプロジェクト", + "settings.projectTitle": "プロジェクトの設定", + "settings.projectNotice": "このプロジェクトの既定モデルと資料の索引を設定します。アカウント、言語、インターネットの許可は、このコンピューターの設定にあります。", + "settings.projectModel": "このプロジェクトの既定モデル", + "settings.conversationModel": "この会話のモデル", + "settings.computerTitle": "このコンピューターの設定", + "settings.computerNotice": "モデルのアカウント、表示言語、インターネットの許可、ローカル検索の構成要素を設定します。すべてのプロジェクトに適用されます。", + "settings.noModel": "接続されたモデルがありません。モデルを接続すると、ここで既定のモデルを選べます。", + "settings.openComputer": "このコンピューターの設定を開く", + "model.chooseBelow": "変更するには、下でモデルを選んでください。", "workspace.settings": "設定", "workspace.reviewInfo": "レビュー環境のモデルと使用状況を確認します。", - "workspace.localToolsInfo": "このコンピューターのモデルとプロジェクトのツールを管理します。", "workspace.reviewModel": "レビュー用のモデル", "workspace.operatorModel": "このワークスペースは、運営者の OpenAI モデルを使います。ブラウザにキーを入力しないでください。", "workspace.modelDefaults": "モデルの既定値", @@ -87,7 +90,6 @@ export const workspaceJa: ColumnOf = { "workspace.reopen": "続きを読む", "workspace.firstQuestion": "最初の質問を送ると、このプロジェクトにセッションが作られます。", "workspace.noProjects": "プロジェクトはまだありません。", - "workspace.settingsTools": "設定とローカルツール", "workspace.testMode": "検証モード", "workspace.checkingProject": "プロジェクトを確認しています", "workspace.selectProject": "プロジェクトを選択", @@ -105,12 +107,13 @@ export const workspaceJa: ColumnOf = { "readiness.consentOff": "インターネットを使わない", "readiness.consentUndecided": "インターネット使用は未決定", "readiness.readyNotice": "質問を送ると、接続した資料から根拠を探し、考えられる説明と次の行動をまとめて整理します。", - "workspace.openSetup": "設定を開く", "workspace.sessionsCount": "セッション", "workspace.sourcesCount": "資料", "workspace.countsLabel": "このプロジェクトの件数", "workspace.locationUnsaved": "ワークスペースの場所は保存されていません", "workspace.taskSelectionNeeded": "保存済みのタスクの選択を確認", + "workspace.projectList": "プロジェクト一覧", + "workspace.setupNeededHint": "新しい調査には設定が必要です。押すと設定を開きます。", "workspace.refreshContext": "現在の文脈を更新", "workspace.conversation": "会話", "workspace.trace": "追跡表", @@ -122,7 +125,6 @@ export const workspaceJa: ColumnOf = { "workspace.chooseProjectNotice": "プロジェクトを選択", "workspace.selectForInfo": "プロジェクトを選ぶと、ワークスペース情報と使用量を確認できます。", "workspace.connectModel": "モデルの接続", - "workspace.selectForDefaults": "プロジェクトを選ぶと、その既定のモデルを設定できます。", "workspace.checkingAccess": "選択したタスクへのアクセスを確認しています…", "workspace.selectionCheck": "保存済みのタスクの選択を確認しています…", "workspace.checkAgain": "もう一度確認", diff --git a/apps/web/src/i18n/jaScreen.test.ts b/apps/web/src/i18n/jaScreen.test.ts index 08d14df8..023d4f71 100644 --- a/apps/web/src/i18n/jaScreen.test.ts +++ b/apps/web/src/i18n/jaScreen.test.ts @@ -95,7 +95,9 @@ it("puts Japanese fonts first on the Japanese screen only, on the page and on bo expect(faces.indexOf("Meiryo")).toBeGreaterThan(faces.indexOf("Yu Gothic UI")); expect(faces).toEqual(expect.arrayContaining(["Hiragino Sans", "Noto Sans JP"])); expect(faces.some(face => /Malgun|Pretendard|Noto Sans KR|Apple SD|YaHei|PingFang|Noto Sans SC/.test(face))).toBe(false); - expect(css.match(/:lang\(/g)).toHaveLength(6); + // No other language gets a font rule. (Korean has a line-breaking rule, which sets no font.) + const fontRules = [...css.matchAll(/([^{}]*:lang\([^{}]*)\{([^}]*)\}/g)].filter(found => /font/.test(found[2])); + expect(fontRules).toHaveLength(3); expect(rule![1]).not.toMatch(/code-font/); }); diff --git a/apps/web/src/i18n/researchPanelsMessages.ts b/apps/web/src/i18n/researchPanelsMessages.ts index 4adf4ada..c93c30d7 100644 --- a/apps/web/src/i18n/researchPanelsMessages.ts +++ b/apps/web/src/i18n/researchPanelsMessages.ts @@ -291,10 +291,6 @@ export const researchPanelsMessages = { "ko": "실제 조사에서 어떤 기억을 불러올지는 질문마다 달라서 이 화면에서 정하지 않습니다. 기억은 직접 고치지 않고 수정 제안으로 새 버전을 만들며, 원래 기억은 이력에 남습니다.", "en": "Memory recall depends on each research question and is not decided by this screen. Corrections create proposed new versions; the original memory remains in history." }, - "historyWorkspace.intro": { - "ko": "질문과 답변, 판단이 달라진 과정을 확인하세요.", - "en": "Review questions, answers, and how assessments changed." - }, "historyWorkspace.checkpoints": { "ko": "중간 저장 포함", "en": "Include checkpoints" diff --git a/apps/web/src/i18n/setupMessages.ts b/apps/web/src/i18n/setupMessages.ts index cd544b91..9cfb3ffa 100644 --- a/apps/web/src/i18n/setupMessages.ts +++ b/apps/web/src/i18n/setupMessages.ts @@ -116,9 +116,10 @@ export const setupMessages = { "en": "Connected. Next, choose whether to allow web access." }, "setup.modelNeeded": { - "ko": "모델을 하나 연결하면 다음으로 넘어갈 수 있습니다.", - "en": "Connect one model to continue." + "ko": "모델을 하나 연결하면 바로 질문할 수 있습니다. 나중에 연결하면 질문을 보낼 때 연결을 요청합니다.", + "en": "Connect one model to ask questions right away. If you connect later, you are asked when you send a question." }, + "setup.connectLater": { "ko": "나중에 연결", "en": "Connect later" }, "setup.next": { "ko": "다음", "en": "Next" @@ -216,8 +217,8 @@ export const setupMessages = { "en": "No project model specified" }, "model.inheritsProject": { - "ko": "이 작업은 프로젝트의 모델 설정을 따릅니다.", - "en": "This task inherits the project's model settings." + "ko": "이 대화는 프로젝트의 모델 설정을 따릅니다.", + "en": "This conversation follows the project's model settings." }, "model.researchModel": { "ko": "연구 모델", @@ -236,11 +237,11 @@ export const setupMessages = { "en": "Default" }, "model.saveThread": { - "ko": "이 작업의 기본값으로 저장", - "en": "Save as this task's default" + "ko": "이 대화의 기본값으로 저장", + "en": "Save as this conversation's default" }, "model.saveProject": { - "ko": "프로젝트 기본값으로 저장", + "ko": "이 프로젝트의 기본값으로 저장", "en": "Save as the project default" }, "model.nextRequestOnly": { diff --git a/apps/web/src/i18n/sourceMessages.ts b/apps/web/src/i18n/sourceMessages.ts index d764816f..2c774c03 100644 --- a/apps/web/src/i18n/sourceMessages.ts +++ b/apps/web/src/i18n/sourceMessages.ts @@ -62,12 +62,12 @@ export const sourceMessages = { "en": "Explicitly permitted tasks only" }, "sources.connectFailed": { - "ko": "자료 연결 실패 / 부분 연결 가능", - "en": "Source connection failed / some files may be connected" + "ko": "자료를 연결하지 못했습니다", + "en": "The source was not connected" }, - "sources.checkConnected": { - "ko": "이미 연결된 파일은 자료 목록에서 확인하세요.", - "en": "Check the source list for files already connected." + "sources.scopeHelp": { + "ko": "'이 프로젝트에서 함께 사용'은 이 프로젝트의 모든 작업이 이 자료를 근거로 쓸 수 있게 합니다. '명시적으로 허용된 작업만'은 따로 허용한 작업에서만 쓰게 합니다.", + "en": "'Shared within this project' lets every task in this project use the source as evidence. 'Explicitly permitted tasks only' limits it to tasks that were given permission." }, "sources.connected": { "ko": "자료 연결이 완료되었습니다. 분석은 새 질문이나 지시로 시작합니다.", @@ -141,6 +141,11 @@ export const sourceMessages = { "ko": "조회할 수 있는 도메인을 먼저 등록하세요. 저장하거나 켜는 것만으로 검색이 시작되지는 않습니다.", "en": "Register permitted domains first. Saving or enabling them does not start a search." }, + "web.allowInSettings": { "ko": "설정에서 허용", "en": "Allow in settings" }, + "web.deniedNext": { + "ko": "허용하면 여기서 조회할 도메인을 등록할 수 있습니다.", + "en": "Once it is allowed, you can register the domains to look up here." + }, "web.workspaceDenied": { "ko": "이 작업 공간에서는 인터넷 사용을 허용하지 않았습니다.", "en": "Internet access is not allowed for this workspace." diff --git a/apps/web/src/i18n/statusResources.ts b/apps/web/src/i18n/statusResources.ts index 6cd458e0..4d74b6b2 100644 --- a/apps/web/src/i18n/statusResources.ts +++ b/apps/web/src/i18n/statusResources.ts @@ -36,7 +36,7 @@ counterReview: { ELIMINATED_WITHIN_SCOPE: "이 범위에서는 반박됨", UNRESOLVED_NO_RESULTS: "반대 근거 탐색에서 결과가 없어 미해결", UNRESOLVED_INDEPENDENCE: "찾은 근거가 독립적이지 않아 미해결", - UNRESOLVED_AUTHORITY: "찾은 근거의 권위를 확인하지 못해 미해결", + UNRESOLVED_AUTHORITY: "찾은 근거의 자료 성격을 확인하지 못해 미해결", UNRESOLVED_TEMPORAL: "찾은 근거의 시점이 맞지 않아 미해결", UNRESOLVED_PROHIBITED_CONTEXT: "사용이 금지된 맥락이라 미해결", UNRESOLVED_POLICY_BLOCKED: "정책상 탐색이 막혀 미해결", @@ -71,7 +71,7 @@ actionFamilies: { }, memoryExclusions: { PROJECT_MISMATCH: "다른 프로젝트의 기억", RESOURCE_ACCESS_DENIED: "접근 권한이 없음", AMBIGUOUS_OR_CONFLICTING: "근거가 모호하거나 충돌함", - AUTHORITY_INVALID: "출처 권위가 확인되지 않음", CUTOFF_INVALID: "기준시점이 맞지 않음", OWNER_REVISION_NOT_CURRENT: "원본이 바뀌어 다시 확인이 필요함", + AUTHORITY_INVALID: "자료 성격이 확인되지 않음", CUTOFF_INVALID: "기준시점이 맞지 않음", OWNER_REVISION_NOT_CURRENT: "원본이 바뀌어 다시 확인이 필요함", DEPENDENCY_REVIEW_REQUIRED: "근거가 바뀌어 재검토가 필요함", RECALL_INELIGIBLE: "다시 불러오지 않는 기억", ACTION_INELIGIBLE: "행동 계획에는 쓰지 않는 기억", SCOPE_MISMATCH: "이 작업 범위와 맞지 않음", QUERY_IRRELEVANT: "이번 질문과 관련이 낮음", NOT_CURRENT: "현재 버전이 아님", RECALL_CLASS_NOT_ACTIVE: "지금 쓰지 않는 종류", BUDGET: "분량 제한으로 제외", diff --git a/apps/web/src/i18n/uiFormatMessages.ts b/apps/web/src/i18n/uiFormatMessages.ts index 88095f1a..5542503b 100644 --- a/apps/web/src/i18n/uiFormatMessages.ts +++ b/apps/web/src/i18n/uiFormatMessages.ts @@ -1,11 +1,16 @@ export const uiFormats = { openExpectedEvidence: {ko: (number: number) => `근거 ${number} 열기`, en: (number: number) => `Open evidence ${number}`, "zh-Hans": (number: number)=>`打开证据 ${number}`, "zh-Hant": (number: number)=>`開啟證據 ${number}`, ja: (number: number)=>`根拠 ${number} を開く`}, openHypothesisEvidence: {ko: (hypothesis: number | string, number: number) => `가설 ${hypothesis}의 근거 ${number} 열기`, en: (hypothesis: number | string, number: number) => `Open evidence ${number} for Hypothesis ${hypothesis}`, "zh-Hans": (hypothesis: number | string, number: number)=>`打开假设 ${hypothesis} 的证据 ${number}`, "zh-Hant": (hypothesis: number | string, number: number)=>`開啟假設 ${hypothesis} 的證據 ${number}`, ja: (hypothesis: number | string, number: number)=>`仮説 ${hypothesis} の根拠 ${number} を開く`}, + uploadUnsupported: {ko: (name: string, formats: string) => `"${name}" 파일은 올릴 수 없는 형식입니다. 올릴 수 있는 형식: ${formats}.`, en: (name: string, formats: string) => `"${name}" is not a file type that can be uploaded. Supported types: ${formats}.`, "zh-Hans": (name: string, formats: string) => `“${name}”的格式无法上传。可上传的格式:${formats}。`, "zh-Hant": (name: string, formats: string) => `「${name}」的格式無法上傳。可上傳的格式:${formats}。`, ja: (name: string, formats: string) => `「${name}」はアップロードできない形式です。アップロードできる形式:${formats}。`}, + uploadEmpty: {ko: (name: string) => `"${name}" 파일이 비어 있습니다. 내용이 있는 파일을 올리세요.`, en: (name: string) => `"${name}" is empty. Upload a file that has content.`, "zh-Hans": (name: string) => `“${name}”是空文件。请上传有内容的文件。`, "zh-Hant": (name: string) => `「${name}」是空檔案。請上傳有內容的檔案。`, ja: (name: string) => `「${name}」は空のファイルです。内容のあるファイルをアップロードしてください。`}, + uploadTooLarge: {ko: (name: string) => `"${name}" 파일이 64MB를 넘습니다. 더 작은 파일로 나눠 올리세요.`, en: (name: string) => `"${name}" is larger than 64 MB. Split it into smaller files.`, "zh-Hans": (name: string) => `“${name}”超过 64MB。请拆分成较小的文件后上传。`, "zh-Hant": (name: string) => `「${name}」超過 64MB。請拆分成較小的檔案後上傳。`, ja: (name: string) => `「${name}」は 64MB を超えています。小さいファイルに分けてアップロードしてください。`}, + batchNoneConnected: {ko: (total: number) => `선택한 파일 ${total}개 가운데 연결된 것은 없습니다.`, en: (total: number) => `None of the ${total} selected files was connected.`, "zh-Hans": (total: number) => `所选的 ${total} 个文件都没有连接。`, "zh-Hant": (total: number) => `所選的 ${total} 個檔案都沒有連線。`, ja: (total: number) => `選択した ${total} 件のファイルは、どれも接続されていません。`}, + batchSomeConnected: {ko: (connected: number, total: number) => `선택한 파일 ${total}개 가운데 앞의 ${connected}개는 연결됐습니다. 자료 목록에서 확인하세요.`, en: (connected: number, total: number) => `${connected} of the ${total} selected files ${connected === 1 ? "was" : "were"} connected before this one. Check the source list.`, "zh-Hans": (connected: number, total: number) => `所选的 ${total} 个文件中,前 ${connected} 个已连接。请在资料列表中确认。`, "zh-Hant": (connected: number, total: number) => `所選的 ${total} 個檔案中,前 ${connected} 個已連線。請在資料列表中確認。`, ja: (connected: number, total: number) => `選択した ${total} 件のうち、先の ${connected} 件は接続されました。資料の一覧で確認してください。`}, + scopeNow: {ko: (scope: string) => `사용 범위: ${scope}`, en: (scope: string) => `Usage scope: ${scope}`, "zh-Hans": (scope: string) => `使用范围:${scope}`, "zh-Hant": (scope: string) => `使用範圍:${scope}`, ja: (scope: string) => `利用範囲:${scope}`}, filesSelected: {ko: (count: number) => `${count}개 선택`, en: (count: number) => `${count} ${count === 1 ? "file" : "files"} selected`, "zh-Hans": (count: number)=>`已选择 ${count} 个`, "zh-Hant": (count: number)=>`已選擇 ${count} 個`, ja: (count: number)=>`${count} 件を選択`}, fileStaging: {ko: (name: string) => `${name} · 업로드 준비 / 구조 파싱`, en: (name: string) => `${name} · Preparing upload / parsing structure`, "zh-Hans": (name: string)=>`${name} · 正在准备上传 / 解析结构`, "zh-Hant": (name: string)=>`${name} · 正在準備上傳 / 解析結構`, ja: (name: string)=>`${name} · アップロードの準備 / 構造の解析`}, fileUploadProgress: {ko: (name: string, percent: number) => `${name} 보내는 중 (${percent}%)`, en: (name: string, percent: number) => `Uploading ${name} (${percent}%)`, "zh-Hans": (name: string, percent: number)=>`正在上传 ${name}(${percent}%)`, "zh-Hant": (name: string, percent: number)=>`正在上傳 ${name}(${percent}%)`, ja: (name: string, percent: number)=>`${name} を送信中(${percent}%)`}, fileUploadWaiting: {ko: (name: string) => `${name} 파일을 다 보냈습니다. 서버가 처리하는 중입니다…`, en: (name: string) => `${name} was sent. The server is processing it…`, "zh-Hans": (name: string)=>`${name} 已发送完毕。服务器正在处理…`, "zh-Hant": (name: string)=>`${name} 已傳送完畢。伺服器正在處理…`, ja: (name: string)=>`${name} を送信し終えました。サーバーが処理しています…`}, - restoreProject: {ko: (name: string) => `${name} 되살리기`, en: (name: string) => `Restore ${name}`, "zh-Hans": (name: string)=>`恢复 ${name}`, "zh-Hant": (name: string)=>`還原 ${name}`, ja: (name: string)=>`${name} を復元`}, currentSetting: {ko: (state: string) => `현재: ${state}`, en: (state: string) => `Current: ${state}`, "zh-Hans": (state: string)=>`当前:${state}`, "zh-Hant": (state: string)=>`目前:${state}`, ja: (state: string)=>`現在:${state}`}, errorCode: {ko: (code: string) => ` 오류 코드: ${code}`, en: (code: string) => ` Error code: ${code}`, "zh-Hans": (code: string)=>` 错误代码:${code}`, "zh-Hant": (code: string)=>` 錯誤程式碼:${code}`, ja: (code: string)=>` エラーコード:${code}`}, loginButton: {ko: (provider: string, connected: boolean) => connected ? `${provider} 로그인됨` : `${provider}로 로그인`, en: (provider: string, connected: boolean) => connected ? `Logged in to ${provider}` : `Log in to ${provider}`, "zh-Hans": (provider: string, connected: boolean) => connected ? `已登录 ${provider}` : `登录 ${provider}`, "zh-Hant": (provider: string, connected: boolean) => connected ? `已登入 ${provider}` : `登入 ${provider}`, ja: (provider: string, connected: boolean) => connected ? `${provider} にログイン済み` : `${provider} にログイン`}, diff --git a/apps/web/src/i18n/wordingRules.test.ts b/apps/web/src/i18n/wordingRules.test.ts index 6b653179..79198b75 100644 --- a/apps/web/src/i18n/wordingRules.test.ts +++ b/apps/web/src/i18n/wordingRules.test.ts @@ -13,7 +13,8 @@ import { joinSentences, sentenceGap } from "./sentences"; afterEach(() => setLocale("ko")); // One word for one thing, and no words of the people who built it (04-ideal I-35, I-36). -const NOT_IN_KOREAN = ["워크스페이스", "작업공간", "원문 근거", "revision", "결정 객체", "계약에 포함"]; +// "권위": the kind of a source is called "자료 성격" everywhere, on the upload form and in the history alike. +const NOT_IN_KOREAN = ["워크스페이스", "작업공간", "원문 근거", "revision", "결정 객체", "계약에 포함", "권위"]; // A dash between a label and its sentence is a habit of machine-written Korean; a colon or a full stop is used. const DASH = "\u2014"; @@ -37,6 +38,13 @@ it("uses one Korean word for the workspace and for the original text, and no int expect(found).toEqual([]); }); +it("calls the kind of a source by one Korean word in the status labels too", () => { + // The Korean status labels are a table of their own, outside the two lists checked above. + const labels = readFileSync(resolve(import.meta.dirname, "statusResources.ts"), "utf8"); + expect(labels).toContain("자료 성격이 확인되지 않음"); + expect(labels).not.toContain("권위"); +}); + // Text between tags that is written in capitals or as a bare English word is not in the translation // lists, so every language would see it in English. The product name is the one that stays. const FIXED_ENGLISH = />\s*([A-Z][A-Za-z]+(?: [A-Za-z]+)*)\s* = { "memory.original": "原始版本", "memory.fingerprint": "记录指纹", "memory.notice": "记忆的调用取决于每个研究问题,不由此屏幕决定。更正会生成建议的新版本;原来的记忆仍保留在历史记录中。", - "historyWorkspace.intro": "查看问题、回答以及评估是如何变化的。", "historyWorkspace.checkpoints": "包含检查点", "historyWorkspace.reload": "重新加载", "historyWorkspace.cursorExpired": "无法再继续此页。请重新加载,从第一页开始。", diff --git a/apps/web/src/i18n/zh/setupMessagesZh.ts b/apps/web/src/i18n/zh/setupMessagesZh.ts index 346e3290..2fb8bb66 100644 --- a/apps/web/src/i18n/zh/setupMessagesZh.ts +++ b/apps/web/src/i18n/zh/setupMessagesZh.ts @@ -31,7 +31,8 @@ export const setupZh: ColumnOf = { "setup.methodsFailed": "无法检查连接方式。请刷新连接状态。", "setup.refreshConnection": "刷新连接状态", "setup.modelReady": "已连接。接下来,请选择是否允许访问网络。", - "setup.modelNeeded": "请先连接一个模型再继续。", + "setup.modelNeeded": "连接一个模型后就可以马上提问。如果稍后连接,发送问题时会要求连接。", + "setup.connectLater": "稍后连接", "setup.next": "下一步", "setup.webQuestion": "是否允许在此工作区访问公共网络?", "setup.hostedDisclosure": "问题和用于分析的资料会发送到运营方的 OpenAI API。网络同意只涵盖资料检索,与模型调用分开。", diff --git a/apps/web/src/i18n/zh/sourceMessagesZh.ts b/apps/web/src/i18n/zh/sourceMessagesZh.ts index 96f10518..eb8b3f71 100644 --- a/apps/web/src/i18n/zh/sourceMessagesZh.ts +++ b/apps/web/src/i18n/zh/sourceMessagesZh.ts @@ -19,8 +19,8 @@ export const sourceZh: ColumnOf = { "sources.chooseScope": "选择使用范围", "sources.projectShared": "在本项目内共享", "sources.explicitGrants": "仅限明确许可的任务", - "sources.connectFailed": "资料连接失败 / 部分文件可能已连接", - "sources.checkConnected": "请查看资料列表,确认哪些文件已经连接。", + "sources.connectFailed": "未能连接资料", + "sources.scopeHelp": "“在本项目内共享”让本项目的所有任务都能把这份资料用作证据。“仅限明确允许的任务”只让单独获得许可的任务使用。", "sources.connected": "资料已连接。请用新的问题或指示开始分析。", "sources.unclassified": "权威性未分类", "sources.informal": "非正式参考", @@ -39,6 +39,8 @@ export const sourceZh: ColumnOf = { "web.consentScope": "允许:在已连接文件之后,研究只能使用项目中登记的网站。这不会开始搜索。拒绝:停用网络检索;模型调用继续。", "web.permittedSources": "允许使用的公共网络资料", "web.registerFirst": "请先登记允许的域名。保存或启用它们并不会开始搜索。", + "web.allowInSettings": "在设置中允许", + "web.deniedNext": "允许后,可以在这里登记要查询的域名。", "web.workspaceDenied": "此工作区不允许访问互联网。", "web.domainLabel": "允许的公共网络域名", "web.domainExample": "例如:arxiv.org", diff --git a/apps/web/src/i18n/zh/workspaceDetailMessagesZh.ts b/apps/web/src/i18n/zh/workspaceDetailMessagesZh.ts index 40b498c5..25651564 100644 --- a/apps/web/src/i18n/zh/workspaceDetailMessagesZh.ts +++ b/apps/web/src/i18n/zh/workspaceDetailMessagesZh.ts @@ -2,7 +2,6 @@ import type { ColumnOf } from "../withColumns"; import type { workspaceDetailMessages } from "../workspaceDetailMessages"; export const workspaceDetailZh: ColumnOf = { - "workspace.questionEvidence": "从问题到证据,再从证据到下一步行动。", "workspace.continueHeading": "继续你的研究,或开始一个新问题。", "workspace.authorizedRead": "在你的访问权限范围内,仍可阅读已保存的研究。", "workspace.readOnlyHold": "在你的权限范围内,已保存的研究仍可使用。新的模型运行和同意变更已暂缓。", diff --git a/apps/web/src/i18n/zh/workspaceMessagesZh.ts b/apps/web/src/i18n/zh/workspaceMessagesZh.ts index bafd85dd..bd56fcc1 100644 --- a/apps/web/src/i18n/zh/workspaceMessagesZh.ts +++ b/apps/web/src/i18n/zh/workspaceMessagesZh.ts @@ -27,13 +27,6 @@ export const workspaceZh: ColumnOf = { "workspace.profile": "领域配置", "workspace.createNotice": "创建项目之后,就可以提问并连接相关的资料。", "workspace.create": "创建项目", - "workspace.sourcesFirst": "先看已连接的资料", - "workspace.sourceNotice": "一并核对原始位置、来源和截止时间。", - "workspace.alternatives": "反面证据与替代解释", - "workspace.alternativesNotice": "在比较假设和行动的同时,保留不确定性和部分结果。", - "workspace.historyTitle": "持续的研究记录", - "workspace.historyNotice": "追溯结果、记忆、修订和恢复的依据。", - "workspace.chooseOrCreate": "继续左侧的项目,或创建新的研究上下文。", "workspace.continue": "继续研究", "workspace.newQuestion": "新问题", "workspace.testModeNotice": "验证模式:此屏幕使用固定的响应和受控的数据。", @@ -43,13 +36,23 @@ export const workspaceZh: ColumnOf = { "workspace.resources": "资料与连接", "workspace.resourcesNotice": "管理研究文件和允许使用的公共网络资料。", "workspace.backConversation": "返回对话", + "workspace.noConnectedSources": "还没有连接的资料。请在下方上传文件进行连接。", "workspace.connectedSources": "已连接的资料", "workspace.cite": "用作引用", "workspace.sourceDetails": "资料与提取状态", "workspace.operations": "工作区信息", + "settings.thisProject": "此项目", + "settings.projectTitle": "项目设置", + "settings.projectNotice": "设置此项目的默认模型和资料索引。账户、语言和联网许可在这台电脑的设置中。", + "settings.projectModel": "此项目的默认模型", + "settings.conversationModel": "此对话的模型", + "settings.computerTitle": "这台电脑的设置", + "settings.computerNotice": "设置模型账户、界面语言、联网许可和本地检索组件。适用于所有项目。", + "settings.noModel": "尚未连接模型。连接模型后,可以在这里选择默认模型。", + "settings.openComputer": "打开这台电脑的设置", + "model.chooseBelow": "如需更改,请在下方选择模型。", "workspace.settings": "设置", "workspace.reviewInfo": "查看审查环境的模型和使用情况。", - "workspace.localToolsInfo": "管理这台电脑上的模型和项目工具。", "workspace.reviewModel": "审查所用模型", "workspace.operatorModel": "此工作区使用运营方的 OpenAI 模型。请不要在浏览器中输入密钥。", "workspace.modelDefaults": "模型默认设置", @@ -87,7 +90,6 @@ export const workspaceZh: ColumnOf = { "workspace.reopen": "继续阅读", "workspace.firstQuestion": "发送你的第一个问题,即可在此项目中创建会话。", "workspace.noProjects": "还没有项目。", - "workspace.settingsTools": "设置与本地工具", "workspace.testMode": "验证模式", "workspace.checkingProject": "正在检查项目", "workspace.selectProject": "选择项目", @@ -105,12 +107,13 @@ export const workspaceZh: ColumnOf = { "readiness.consentOff": "不使用互联网", "readiness.consentUndecided": "互联网使用未决定", "readiness.readyNotice": "发送问题后,THOTH 会在已连接的资料中查找证据,并一并整理可能的解释与下一步行动。", - "workspace.openSetup": "前往设置", "workspace.sessionsCount": "会话", "workspace.sourcesCount": "资料", "workspace.countsLabel": "此项目的数量", "workspace.locationUnsaved": "未保存工作区位置", "workspace.taskSelectionNeeded": "检查已保存的任务选择", + "workspace.projectList": "项目列表", + "workspace.setupNeededHint": "新研究需要先完成设置。点击后打开设置。", "workspace.refreshContext": "刷新当前上下文", "workspace.conversation": "对话", "workspace.trace": "追溯表", @@ -122,7 +125,6 @@ export const workspaceZh: ColumnOf = { "workspace.chooseProjectNotice": "选择项目", "workspace.selectForInfo": "选择项目以查看工作区信息和使用量。", "workspace.connectModel": "模型连接", - "workspace.selectForDefaults": "选择项目以设置其默认模型。", "workspace.checkingAccess": "正在检查对所选任务的访问权限…", "workspace.selectionCheck": "正在检查已保存的任务选择…", "workspace.checkAgain": "再次检查", diff --git a/apps/web/src/i18n/zhHant/researchPanelsMessagesZhHant.ts b/apps/web/src/i18n/zhHant/researchPanelsMessagesZhHant.ts index a06436b3..aacc5956 100644 --- a/apps/web/src/i18n/zhHant/researchPanelsMessagesZhHant.ts +++ b/apps/web/src/i18n/zhHant/researchPanelsMessagesZhHant.ts @@ -75,7 +75,6 @@ export const researchPanelsZhHant: ColumnOf = { "memory.original": "原始版本", "memory.fingerprint": "記錄指紋", "memory.notice": "記憶的呼叫取決於每個研究問題,不由此螢幕決定。更正會生成建議的新版本;原來的記憶仍保留在歷史記錄中。", - "historyWorkspace.intro": "檢視問題、回答以及評估是如何變化的。", "historyWorkspace.checkpoints": "包含檢查點", "historyWorkspace.reload": "重新載入", "historyWorkspace.cursorExpired": "無法再繼續此頁。請重新載入,從第一頁開始。", diff --git a/apps/web/src/i18n/zhHant/setupMessagesZhHant.ts b/apps/web/src/i18n/zhHant/setupMessagesZhHant.ts index 07a82a63..c660c87a 100644 --- a/apps/web/src/i18n/zhHant/setupMessagesZhHant.ts +++ b/apps/web/src/i18n/zhHant/setupMessagesZhHant.ts @@ -31,7 +31,8 @@ export const setupZhHant: ColumnOf = { "setup.methodsFailed": "無法檢查連線方式。請重新整理連線狀態。", "setup.refreshConnection": "重新整理連線狀態", "setup.modelReady": "已連線。接下來,請選擇是否允許存取網路。", - "setup.modelNeeded": "請先連線一個模型再繼續。", + "setup.modelNeeded": "連線一個模型後就可以馬上提問。如果稍後連線,傳送問題時會要求連線。", + "setup.connectLater": "稍後連線", "setup.next": "下一步", "setup.webQuestion": "是否允許在此工作區存取公共網路?", "setup.hostedDisclosure": "問題和用於分析的資料會傳送到運營方的 OpenAI API。網路同意只涵蓋資料檢索,與模型呼叫分開。", diff --git a/apps/web/src/i18n/zhHant/sourceMessagesZhHant.ts b/apps/web/src/i18n/zhHant/sourceMessagesZhHant.ts index 6e798e32..bf30d2b2 100644 --- a/apps/web/src/i18n/zhHant/sourceMessagesZhHant.ts +++ b/apps/web/src/i18n/zhHant/sourceMessagesZhHant.ts @@ -19,8 +19,8 @@ export const sourceZhHant: ColumnOf = { "sources.chooseScope": "選擇使用範圍", "sources.projectShared": "在本專案內共享", "sources.explicitGrants": "僅限明確許可的任務", - "sources.connectFailed": "資料連線失敗 / 部分檔案可能已連線", - "sources.checkConnected": "請檢視資料列表,確認哪些檔案已經連線。", + "sources.connectFailed": "未能連線資料", + "sources.scopeHelp": "「在本專案內共用」讓本專案的所有任務都能把這份資料用作證據。「僅限明確允許的任務」只讓個別獲得許可的任務使用。", "sources.connected": "資料已連線。請用新的問題或指示開始分析。", "sources.unclassified": "權威性未分類", "sources.informal": "非正式參考", @@ -39,6 +39,8 @@ export const sourceZhHant: ColumnOf = { "web.consentScope": "允許:在已連線檔案之後,研究只能使用專案中登記的網站。這不會開始搜尋。拒絕:停用網路檢索;模型呼叫繼續。", "web.permittedSources": "允許使用的公共網路資料", "web.registerFirst": "請先登記允許的域名。儲存或啟用它們並不會開始搜尋。", + "web.allowInSettings": "在設定中允許", + "web.deniedNext": "允許後,可以在這裡登記要查詢的網域。", "web.workspaceDenied": "此工作區不允許存取網際網路。", "web.domainLabel": "允許的公共網路域名", "web.domainExample": "例如:arxiv.org", diff --git a/apps/web/src/i18n/zhHant/workspaceDetailMessagesZhHant.ts b/apps/web/src/i18n/zhHant/workspaceDetailMessagesZhHant.ts index 207db727..67fddb7b 100644 --- a/apps/web/src/i18n/zhHant/workspaceDetailMessagesZhHant.ts +++ b/apps/web/src/i18n/zhHant/workspaceDetailMessagesZhHant.ts @@ -2,7 +2,6 @@ import type { ColumnOf } from "../withColumns"; import type { workspaceDetailMessages } from "../workspaceDetailMessages"; export const workspaceDetailZhHant: ColumnOf = { - "workspace.questionEvidence": "從問題到證據,再從證據到下一步行動。", "workspace.continueHeading": "繼續你的研究,或開始一個新問題。", "workspace.authorizedRead": "在你的存取權限範圍內,仍可閱讀已儲存的研究。", "workspace.readOnlyHold": "在你的權限範圍內,已儲存的研究仍可使用。新的模型執行和同意變更已暫緩。", diff --git a/apps/web/src/i18n/zhHant/workspaceMessagesZhHant.ts b/apps/web/src/i18n/zhHant/workspaceMessagesZhHant.ts index 2b55fd2a..04caf222 100644 --- a/apps/web/src/i18n/zhHant/workspaceMessagesZhHant.ts +++ b/apps/web/src/i18n/zhHant/workspaceMessagesZhHant.ts @@ -27,13 +27,6 @@ export const workspaceZhHant: ColumnOf = { "workspace.profile": "領域配置", "workspace.createNotice": "建立專案之後,就可以提問並連線相關的資料。", "workspace.create": "建立專案", - "workspace.sourcesFirst": "先看已連線的資料", - "workspace.sourceNotice": "一併核對原始位置、來源和截止時間。", - "workspace.alternatives": "反面證據與替代解釋", - "workspace.alternativesNotice": "在比較假設和行動的同時,保留不確定性和部分結果。", - "workspace.historyTitle": "持續的研究記錄", - "workspace.historyNotice": "追溯結果、記憶、修訂和還原的依據。", - "workspace.chooseOrCreate": "繼續左側的專案,或建立新的研究上下文。", "workspace.continue": "繼續研究", "workspace.newQuestion": "新問題", "workspace.testModeNotice": "驗證模式:此螢幕使用固定的回應和受控的資料。", @@ -43,13 +36,23 @@ export const workspaceZhHant: ColumnOf = { "workspace.resources": "資料與連線", "workspace.resourcesNotice": "管理研究檔案和允許使用的公共網路資料。", "workspace.backConversation": "返回對話", + "workspace.noConnectedSources": "還沒有連線的資料。請在下方上傳檔案進行連線。", "workspace.connectedSources": "已連線的資料", "workspace.cite": "用作引用", "workspace.sourceDetails": "資料與提取狀態", "workspace.operations": "工作區資訊", + "settings.thisProject": "此專案", + "settings.projectTitle": "專案設定", + "settings.projectNotice": "設定此專案的預設模型和資料索引。帳戶、語言和連網許可在這臺電腦的設定中。", + "settings.projectModel": "此專案的預設模型", + "settings.conversationModel": "此對話的模型", + "settings.computerTitle": "這臺電腦的設定", + "settings.computerNotice": "設定模型帳戶、介面語言、連網許可和本機檢索元件。適用於所有專案。", + "settings.noModel": "尚未連線模型。連線模型後,可以在這裡選擇預設模型。", + "settings.openComputer": "開啟這臺電腦的設定", + "model.chooseBelow": "如需變更,請在下方選擇模型。", "workspace.settings": "設定", "workspace.reviewInfo": "檢視審查環境的模型和使用情況。", - "workspace.localToolsInfo": "管理這臺電腦上的模型和專案工具。", "workspace.reviewModel": "審查所用模型", "workspace.operatorModel": "此工作區使用運營方的 OpenAI 模型。請不要在瀏覽器中輸入金鑰。", "workspace.modelDefaults": "模型預設設定", @@ -87,7 +90,6 @@ export const workspaceZhHant: ColumnOf = { "workspace.reopen": "繼續閱讀", "workspace.firstQuestion": "傳送你的第一個問題,即可在此專案中建立會話。", "workspace.noProjects": "還沒有專案。", - "workspace.settingsTools": "設定與本地工具", "workspace.testMode": "驗證模式", "workspace.checkingProject": "正在檢查專案", "workspace.selectProject": "選擇專案", @@ -105,12 +107,13 @@ export const workspaceZhHant: ColumnOf = { "readiness.consentOff": "不使用網際網路", "readiness.consentUndecided": "網際網路使用未決定", "readiness.readyNotice": "傳送問題後,THOTH 會在已連線的資料中查詢證據,並一併整理可能的解釋與下一步行動。", - "workspace.openSetup": "前往設定", "workspace.sessionsCount": "會話", "workspace.sourcesCount": "資料", "workspace.countsLabel": "此專案的數量", "workspace.locationUnsaved": "未儲存工作區位置", "workspace.taskSelectionNeeded": "檢查已儲存的任務選擇", + "workspace.projectList": "專案清單", + "workspace.setupNeededHint": "新研究需要先完成設定。點擊後開啟設定。", "workspace.refreshContext": "重新整理目前上下文", "workspace.conversation": "對話", "workspace.trace": "追溯表", @@ -122,7 +125,6 @@ export const workspaceZhHant: ColumnOf = { "workspace.chooseProjectNotice": "選擇專案", "workspace.selectForInfo": "選擇專案以檢視工作區資訊和使用量。", "workspace.connectModel": "模型連線", - "workspace.selectForDefaults": "選擇專案以設定其預設模型。", "workspace.checkingAccess": "正在檢查對所選任務的存取權限…", "workspace.selectionCheck": "正在檢查已儲存的任務選擇…", "workspace.checkAgain": "再次檢查", diff --git a/apps/web/src/i18n/zhHantScreen.test.ts b/apps/web/src/i18n/zhHantScreen.test.ts index 510c0e2b..4d52bf45 100644 --- a/apps/web/src/i18n/zhHantScreen.test.ts +++ b/apps/web/src/i18n/zhHantScreen.test.ts @@ -111,7 +111,9 @@ it("puts Traditional Chinese fonts first on the Traditional screen only, on the expect(faces.indexOf("PingFang TC")).toBeGreaterThan(faces.indexOf("Microsoft JhengHei UI")); expect(faces).toEqual(expect.arrayContaining(["Noto Sans TC", "Source Han Sans TC"])); expect(faces.some(face => /Malgun|Pretendard|Noto Sans KR|Apple SD|YaHei|PingFang SC|Noto Sans SC|Yu Gothic|Meiryo|Noto Sans JP/.test(face))).toBe(false); - expect(css.match(/:lang\(/g)).toHaveLength(6); + // No other language gets a font rule. (Korean has a line-breaking rule, which sets no font.) + const fontRules = [...css.matchAll(/([^{}]*:lang\([^{}]*)\{([^}]*)\}/g)].filter(found => /font/.test(found[2])); + expect(fontRules).toHaveLength(3); expect(rule![1]).not.toMatch(/code-font/); }); diff --git a/apps/web/src/i18n/zhScreen.test.ts b/apps/web/src/i18n/zhScreen.test.ts index 95d08b24..5dd1a040 100644 --- a/apps/web/src/i18n/zhScreen.test.ts +++ b/apps/web/src/i18n/zhScreen.test.ts @@ -94,7 +94,9 @@ it("puts Chinese fonts first on the Chinese screen only, on the page and on body expect(faces.indexOf("PingFang SC")).toBeGreaterThan(faces.indexOf("Microsoft YaHei UI")); expect(faces).toContain("Noto Sans SC"); expect(faces.some(face => /Malgun|Pretendard|Noto Sans KR|Apple SD/.test(face))).toBe(false); - expect(workstationCss.match(/:lang\(/g)).toHaveLength(6); + // No other language gets a font rule. (Korean has a line-breaking rule, which sets no font.) + const fontRules = [...workstationCss.matchAll(/([^{}]*:lang\([^{}]*)\{([^}]*)\}/g)].filter(found => /font/.test(found[2])); + expect(fontRules).toHaveLength(3); expect(rule![1]).not.toMatch(/code-font/); }); diff --git a/apps/web/src/narrowLayoutRules.test.ts b/apps/web/src/narrowLayoutRules.test.ts new file mode 100644 index 00000000..eda018de --- /dev/null +++ b/apps/web/src/narrowLayoutRules.test.ts @@ -0,0 +1,111 @@ +import { readFileSync } from "node:fs"; +import { expect, it } from "vitest"; + +// Rules of the narrow layout that have no behaviour jsdom could run. The arrangement itself is measured +// in a browser (outputs: qa_b3.py); these lines keep the stylesheet from sliding back into the old one. + +const css = readFileSync(new URL("./workstation.css", import.meta.url), "utf8"); + +type Rule = { media: string | null; selector: string; body: string }; + +/** Every rule of the stylesheet with the media query it sits in (one level of nesting is all the file uses). */ +function rules(source: string): Rule[] { + const text = source.replace(/\/\*[\s\S]*?\*\//g, ""); + const out: Rule[] = []; + let media: string | null = null; + let at = 0; + while (at < text.length) { + const open = text.indexOf("{", at); + const close = text.indexOf("}", at); + if (close !== -1 && (open === -1 || close < open)) { + media = null; + at = close + 1; + continue; + } + if (open === -1) break; + const head = text.slice(at, open).trim(); + if (head.startsWith("@media")) { + media = head.replace(/\s+/g, ""); + at = open + 1; + continue; + } + const end = text.indexOf("}", open); + out.push({ media, selector: head.replace(/\s+/g, " "), body: text.slice(open + 1, end).replace(/\s+/g, " ").trim() }); + at = end + 1; + } + return out; +} + +const all = rules(css); +const maxWidth = (rule: Rule) => { + const found = rule.media?.match(/max-width:(\d+)px/); + return found ? Number(found[1]) : null; +}; +const narrow = all.filter(rule => (maxWidth(rule) ?? Infinity) <= 760); +const has = (rule: Rule, selector: string) => rule.selector.split(",").some(part => part.trim() === selector); +const declares = (rule: Rule, property: string) => new RegExp("(^|;|\\s)" + property + "\\s*:").test(rule.body); + +it("takes the project list out of the page at 760px and below, in one place", () => { + const list = narrow.filter(rule => has(rule, ".workstation.conversation-workstation > .navigation-rail") && declares(rule, "position")); + expect(list).toHaveLength(1); + expect(list[0].body).toContain("position: fixed"); + // Closed, it is not drawn: it cannot be focused or read out while it is off screen. + expect(list[0].body).toContain("visibility: hidden"); + const open = narrow.filter(rule => has(rule, '.workstation.conversation-workstation > .navigation-rail[data-open="true"]') && declares(rule, "visibility")); + expect(open).toHaveLength(1); + expect(open[0].body).toContain("visibility: visible"); + // No narrow rule gives the list a column of its own again. + const columns = narrow.filter(rule => rule.selector.includes(".conversation-workstation") && declares(rule, "grid-template-columns")); + expect(columns.map(rule => rule.body)).toEqual(["grid-template-columns: minmax(0, 1fr);"]); +}); + +it("shows the list button only where the list is a drawer", () => { + const button = all.filter(rule => has(rule, ".workstation-topbar .nav-drawer-button") && declares(rule, "display")); + expect(button.map(rule => [maxWidth(rule), /display:\s*([a-z-]+)/.exec(rule.body)![1]])).toEqual([[null, "none"], [760, "inline-flex"]]); +}); + +it("does not cut project and conversation names to a fixed width in a narrow window", () => { + const names = all.filter(rule => maxWidth(rule) !== null && /(project|thread)-button strong/.test(rule.selector) && declares(rule, "max-width")); + expect(names.map(rule => /max-width:\s*([^;]+)/.exec(rule.body)![1].trim())).toEqual(["none"]); +}); + +it("folds top bar tags to their icons without removing their words", () => { + const folded = all.filter(rule => maxWidth(rule) !== null + && (has(rule, ".workstation-topbar .header-cutoff > span:not(.bp6-icon)") || has(rule, ".workstation-topbar .setup-needed-button > .bp6-button-text"))); + expect(folded).toHaveLength(2); + for (const rule of folded) { + expect(rule.body).toContain("clip-path: inset(50%)"); + expect(declares(rule, "display")).toBe(false); + } + // A failed connection keeps its tag in the narrow bar. + const health = narrow.filter(rule => rule.selector.includes(".header-health") && declares(rule, "display")); + expect(health.map(rule => rule.selector)).toEqual(['.workstation-topbar .header-health:not([data-health="failed"])']); +}); + +it("covers the conversation with the detail panel where there is no room for both, and hides what is under it", () => { + const cover = all.filter(rule => maxWidth(rule) === 1100 && has(rule, ".research-detail-pane") && has(rule, ".research-side-panel")); + expect(cover).toHaveLength(1); + expect(cover[0].body).toContain("position: absolute"); + expect(cover[0].body).toContain("inset: 0"); + const under = all.filter(rule => maxWidth(rule) === 1100 && has(rule, ".conversation-layout.detail-open > .conversation-center")); + expect(under.map(rule => rule.body)).toEqual(["visibility: hidden;"]); +}); + +it("breaks Korean lines between words, and leaves text marked as another language alone", () => { + const korean = all.filter(rule => has(rule, "html:lang(ko)")); + expect(korean).toHaveLength(1); + expect(korean[0].media).toBeNull(); + expect(korean[0].body).toContain("word-break: keep-all"); + // "anywhere" would let a flex item shrink to one letter, which is how the top bar came to stack its letters. + expect(korean[0].body).toContain("overflow-wrap: break-word"); + const other = all.filter(rule => has(rule, "[lang]:not(:lang(ko))")); + expect(other.map(rule => rule.body)).toEqual(["word-break: normal;"]); +}); + +it("never shortens the state tag of a provider on the first-run screen", () => { + const tag = all.filter(rule => has(rule, ".first-run-provider-head > .bp6-tag")); + expect(tag.map(rule => rule.body)).toEqual(["flex: 0 0 auto;"]); + const head = all.filter(rule => has(rule, ".first-run-provider-head")); + expect(head[0].body).toContain("flex-wrap: wrap"); +}); + diff --git a/apps/web/src/workstation.css b/apps/web/src/workstation.css index 1ab90be9..2793443e 100644 --- a/apps/web/src/workstation.css +++ b/apps/web/src/workstation.css @@ -39,6 +39,11 @@ .history-detail-actions .bp6-button { min-height: 36px; } .history-help,.history-empty-copy { color: var(--muted); font-size: 12px; line-height: 1.7; } .history-coverage { color: var(--amber); font-size: 12px; line-height: 1.7; padding: 8px; } +/* How much of the history this list covers is information, not a warning: the warning colour is kept for + notes that ask the reader to do something. */ +.history-coverage-note { color: var(--muted); font-size: 12px; line-height: 1.7; padding: 8px; } +.project-review-empty { margin: 0 0 12px; } +.history-open-full { margin-top: 12px; } .history-empty { padding: 45px 12px; color: var(--muted); text-align: center; }.history-empty h3 { color: var(--text); margin-top: 16px; }.history-empty p { font-size: 12px; } .history-technical { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--line-soft); color: var(--muted); font-size: 12px; } .history-technical summary,.history-change summary,.restore-impact summary { cursor: pointer; padding: 6px 0; } @@ -53,8 +58,6 @@ .history-restore-dialog { width: min(780px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); margin: 20px 0; } .history-restore-dialog .bp6-dialog-body { overflow: auto; min-height: 0; }.history-restore-dialog .bp6-dialog-footer-actions { flex-wrap: wrap; } .history-restore-dialog p { line-height: 1.7; }.history-restore-dialog .bp6-callout { margin: 16px 0; }.history-restore-dialog code { overflow-wrap: anywhere; } -.history-compact .workspace-heading { margin-bottom: 12px; }.history-compact .workspace-heading h1 { font-size: 20px; } -.history-compact .history-layout { grid-template-columns: 1fr; height: auto; }.history-compact .history-timeline { max-height: 240px; overflow: auto; border-right: 0; border-bottom: 1px solid var(--line); } .history-compact .history-detail { padding: 18px; min-height: 260px; }.history-compact .history-diff-values { grid-template-columns: 1fr; } @media(max-width:1100px) { .history-layout { grid-template-columns: 1fr; height: auto; }.history-timeline { max-height: 340px; overflow: auto; border-right: 0; border-bottom: 1px solid var(--line); }.history-detail { min-height: 300px; }.history-heading { flex-wrap: wrap; } } @media(max-width:600px) { .history-detail { padding: 18px; }.history-toolbar { align-items: flex-start; flex-wrap: wrap; }.history-diff-values,.decision-delta-values { grid-template-columns: 1fr; }.history-diff-values>div+div { border-left: 0; border-top: 1px solid var(--line); }.history-event-meta { flex-wrap: wrap; }.history-detail-actions>.bp6-button { flex-grow: 1; }.history-decision-comparison { padding:12px; }.history-compare-selected { flex-direction:column; } } @@ -102,7 +105,7 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli .workspace-navigation .bp6-tab-list { gap: 26px; }.workspace-navigation .bp6-tab { font-size: 12px;line-height:48px; }.context-policy {display:flex;align-items:center;gap:12px;font-size:11px;color:var(--muted)}.context-policy select {max-width:210px;height:27px;line-height:27px;font-size:11px} .workspace-content {grid-column:2;grid-row:3;min-width:0;padding:28px;max-width:100%;}.workspace-content>.bp6-callout {margin:18px 0}.workspace-heading {display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:24px}.workspace-heading h1 {margin:0 0 9px;font-size:27px;letter-spacing:-.04em;font-weight:600;line-height:1.3}.workspace-heading p:not(.eyebrow){margin:0;line-height:1.7;font-size:12px;color:var(--muted);max-width:780px;white-space:pre-line}.eyebrow {color:var(--accent-bright);font-size:10px;letter-spacing:.14em;font-weight:600} .workstation h2 {font-size:16px;font-weight:600}.workstation h3 {font-size:13px;font-weight:600}.workstation p {line-height:1.65}.workstation .muted {font-size:11px;line-height:1.65;color:var(--muted)}.workstation code {color:var(--muted);font-size:10px;overflow-wrap:anywhere}.workstation .empty-copy {font-size:11px;line-height:1.65;padding:8px}.workstation .bp6-callout {font-size:12px;border-radius:4px;}.workstation .bp6-callout p:last-child{margin-bottom:0} -.workstation .onboarding-card {margin:0;padding:24px;background:var(--surface-1);border:1px solid var(--line);border-radius:var(--radius);max-width:760px}.workstation .onboarding-card label {font-size:12px;display:grid;gap:8px;color:var(--muted);margin-top:14px}.workstation .onboarding-card input:not(.bp6-input) {padding:8px;border:1px solid var(--line);border-radius:3px;background:#1c252f;color:var(--text)}.workstation .onboarding-card .bp6-button {background:#2d72d2;color:#fff;border-radius:3px;font-size:12px;padding:8px 12px} +.workstation .onboarding-card {margin:0;padding:24px;background:var(--surface-1);border:1px solid var(--line);border-radius:var(--radius);max-width:760px}.workstation .onboarding-card label {font-size:12px;display:grid;gap:8px;color:var(--muted);margin-top:14px}.workstation .onboarding-card input:not(.bp6-input) {padding:8px;border:1px solid var(--line);border-radius:3px;background:#1c252f;color:var(--text)}.workstation .onboarding-card .bp6-button.bp6-intent-primary {background:#2d72d2;color:#fff;border-radius:3px;font-size:12px;padding:8px 12px} .start-page {max-width:980px;margin:30px auto;}.start-page>.workspace-heading {padding-bottom:28px;border-bottom:1px solid var(--line-soft)}.start-page>.workspace-heading>.bp6-icon {color:#456887;margin:18px 20px}.start-page h1 {font-size:36px;line-height:1.35}.start-principles {display:grid;grid-template-columns:repeat(3,1fr);gap:28px;margin-top:40px}.start-principles>div{border-top:1px solid var(--line);padding-top:20px}.start-principles .bp6-icon {color:var(--accent-bright)}.start-principles p{color:var(--muted);font-size:12px} .project-home-list{max-width:760px;margin-top:28px}.project-home-list>.section-title-row{margin-bottom:10px}.project-home-item.bp6-button{margin-bottom:8px;padding:14px;background:var(--surface-1);border:1px solid var(--line)}.project-home-item .bp6-button-text{display:flex}.project-home-item strong,.project-home-item small{display:block}.project-home-item small{margin-top:4px;color:var(--muted);font-size:12px} .workstation .prompt-composer {margin:24px 0 0;background:#222d3a;border:1px solid #48627e;border-radius:6px;padding:0;overflow:visible}.workstation .prompt-label-row {padding:15px 17px 10px}.workstation .prompt-label-row label {font-size:12px}.workstation .prompt-label-row span {font-size:10px}.workstation .prompt-composer>textarea {margin:0 16px;width:calc(100% - 32px);resize:vertical;min-height:90px;border:0;border-radius:3px;background:#1c2530;font-size:13px;padding:12px;line-height:1.7;color:var(--text);box-shadow:none}.workstation .composer-footer {padding:12px 16px;border-top:1px solid var(--line-soft)}.workstation .composer-footer>span{font-size:10px}.workstation .composer-footer .bp6-button{background:#2d72d2;border-radius:3px;font-size:12px;padding:8px 12px}.workstation .model-settings{margin:14px 16px;padding:10px 0;border:0;border-top:1px solid var(--line-soft);border-radius:0;gap:10px}.workstation .model-settings legend{font-size:10px;padding:0 8px 0 0}.workstation .model-settings select{background:#253240;border:1px solid #425269;border-radius:3px;font-size:11px;padding:6px 28px 6px 6px}.workstation .model-settings button{border-radius:3px;background:#2f3e50;font-size:10px;padding:6px}.workstation .model-settings small {font-size:10px} @@ -115,7 +118,19 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli @media(min-width:1700px){:root{--sidebar-width:268px;--evidence-width:360px}.workspace-content{padding:32px 40px}.workstation .dimension-grid{grid-template-columns:repeat(4,1fr)}} @media(max-width:1250px){:root{--sidebar-width:220px;--evidence-width:280px}.workspace-content{padding:22px}.context-policy>span{display:none}.catalog-layout{grid-template-columns:280px minmax(0,1fr)}.method-detail{padding:18px}.method-meta{grid-template-columns:1fr}.workstation .brand .bp6-tag{display:none}} @media(max-width:1080px){.workstation.with-evidence{grid-template-columns:var(--sidebar-width) minmax(0,1fr)}.workstation .evidence-rail{grid-column:2;grid-row:4;height:auto;position:static;max-height:600px;border-top:1px solid var(--line)}.context-policy{display:none}.catalog-layout{grid-template-columns:240px minmax(0,1fr)}.budget-strip{grid-template-columns:1fr 1fr}.context-path small{max-width:130px}} -@media(max-width:760px){.workstation,.workstation.with-evidence{display:flex;flex-direction:column}.workstation .navigation-rail{height:auto;position:static;max-height:340px;padding:12px}.workstation .brand{padding-bottom:0}.workstation .sidebar-bottom{display:none}.workstation .nav-section{margin:0}.workstation .thread-section{min-height:0}.workstation .project-button,.workstation .thread-button{min-height:38px;padding:6px}.workstation-topbar{min-height:52px;padding:10px 16px}.context-path small{display:none}.workspace-navigation{padding:0 16px;min-height:49px}.workspace-content{padding:20px 16px}.start-page{margin:10px auto}.start-page h1{font-size:27px}.start-principles{gap:14px;grid-template-columns:1fr}.catalog-layout{grid-template-columns:1fr}.method-list{max-height:250px;min-height:0;border-right:0;border-bottom:1px solid var(--line)}.workstation .evidence-rail{width:100%;max-height:600px}.workstation .source-controls,.budget-strip{grid-template-columns:1fr}.workspace-heading h1{font-size:23px}.record-grid>div{grid-template-columns:1fr;gap:5px}.header-status .bp6-tag{font-size:9px}.workspace-navigation .bp6-tab-list{gap:22px}} +@media (max-width: 760px) { + /* Page content in a narrow window. The shell itself (list, top bar, tabs) is arranged in one block further down. */ + .workspace-content { padding: 20px 16px; } + .start-page { margin: 10px auto; } + .start-page h1 { font-size: 27px; } + .start-principles { gap: 14px; grid-template-columns: 1fr; } + .catalog-layout { grid-template-columns: 1fr; } + .method-list { max-height: 250px; min-height: 0; border-right: 0; border-bottom: 1px solid var(--line); } + .workstation .evidence-rail { width: 100%; max-height: 600px; } + .workstation .source-controls, .budget-strip { grid-template-columns: 1fr; } + .workspace-heading h1 { font-size: 23px; } + .record-grid > div { grid-template-columns: 1fr; gap: 5px; } +} /* Conversation surfaces use the existing workstation tokens and real record views. */ .conversation-workstation {height:100dvh;min-height:0;overflow:hidden;grid-template-columns:var(--sidebar-width) minmax(0,1fr)} .conversation-workstation {grid-template-rows:48px 40px minmax(0,1fr)} @@ -223,11 +238,130 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli .research-detail-pane .audit-trail {min-width:0}.research-detail-pane table {display:block;overflow:auto;max-width:100%} .test-mode-banner {margin:0!important;border-radius:0!important;font-size:11px!important} .project-advanced {margin:18px 0;color:var(--muted)}.project-advanced>.bp6-button{padding-left:0} -@media(max-width:1100px){.conversation-layout.detail-open{grid-template-columns:minmax(0,1fr)}.research-detail-pane{position:absolute;z-index:25;right:0;top:105px;bottom:0;width:min(440px,calc(100vw - 24px));box-shadow:-12px 0 40px #0006}.conversation-timeline{padding:24px}.composer-dock{padding:10px 18px 12px}} -@media(max-width:760px){.conversation-workstation{grid-template-columns:160px minmax(0,1fr)}.conversation-workstation .navigation-rail{padding:12px 8px}.conversation-workstation .brand span,.conversation-workstation .brand .bp6-tag{display:none}.conversation-heading{padding:12px 16px}.conversation-timeline{padding:18px 14px}.user-message{max-width:100%;padding:12px}.composer-dock{padding:8px}.composer-tools{gap:6px}.model-settings.compact select{max-width:112px}.usage-summary{text-align:left;font-size:9px}.workstation-topbar,.workspace-navigation{padding:0 12px}.context-policy{display:none}.conversation-workstation .thread-button strong,.conversation-workstation .project-button strong{max-width:112px}} -@media(max-width:760px){.conversation-workstation{height:auto;min-height:100dvh;overflow:visible}.conversation-workstation .conversation-page{min-height:620px;overflow:visible}.conversation-workstation .conversation-layout,.conversation-workstation .conversation-center{height:auto;min-height:620px}.conversation-workstation .conversation-scroll{min-height:300px;overflow:visible}.legacy-thread-empty{padding:36px 12px 28px}.legacy-thread-empty h1{font-size:21px}.legacy-thread-actions>.bp6-button{flex:1;min-width:150px}} +@media (max-width: 1100px) { + /* There is no room for the conversation and a detail panel side by side: the panel covers the conversation + until it is closed. Half-covering the answer left neither readable. */ + .conversation-layout { position: relative; } + .conversation-layout.detail-open { grid-template-columns: minmax(0, 1fr); } + .conversation-layout.detail-open > .conversation-center { visibility: hidden; } + .research-detail-pane, + .research-side-panel { position: absolute; inset: 0; z-index: 5; border-left: 0; } + .conversation-timeline { padding: 24px; } + .composer-dock { padding: 10px 18px 12px; } +} + +/* ---- Top bar: every item stays on one line, and the names give way first ---- */ +.workstation-topbar .context-path { flex: 1 1 0; overflow: hidden; } +.workstation-topbar .context-path > * { flex-shrink: 0; white-space: nowrap; } +.workstation-topbar .context-path > strong { flex-shrink: 1; min-width: 3em; overflow: hidden; text-overflow: ellipsis; } +.workstation-topbar .context-path > small { flex-shrink: 4; min-width: 4em; } +.workstation-topbar .header-status { flex: 0 0 auto; } +.workstation-topbar .header-status > * { flex-shrink: 0; white-space: nowrap; } +.workstation-topbar .header-warning { max-width: 14em; } +.workstation-topbar .header-cutoff, +.workstation-topbar .setup-needed-button { position: relative; } +.workstation-topbar .nav-drawer-button, +.nav-drawer-backdrop { display: none; } + +@media (max-width: 1080px) { + /* What goes first: the fixed first step of the path (it says the same on every screen) and the + conversation name (it is the heading right below the bar). */ + .workstation-topbar .context-root, + .workstation-topbar .context-thread { display: none; } +} +@media (max-width: 900px) { + /* The cutoff folds to its icon. Its words stay for screen readers and in the tooltip. */ + .workstation-topbar .header-cutoff > span:not(.bp6-icon) { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + .workstation-topbar .header-cutoff > .bp6-icon { margin-right: 0; } +} +/* ---- Narrow window (760px and below): one column, the project list is a drawer ---- */ +@media (max-width: 760px) { + .workstation.conversation-workstation { grid-template-columns: minmax(0, 1fr); } + .workstation.conversation-workstation > * { grid-column: 1; } + + /* The list leaves the page. It is drawn only while open, so it cannot be focused or read while closed. */ + .workstation.conversation-workstation > .navigation-rail { + position: fixed; + inset: 0 auto 0 0; + z-index: 16; + width: min(320px, 86vw); + height: 100dvh; + padding: 16px 12px 12px; + box-shadow: 12px 0 40px #0008; + transform: translateX(-100%); + visibility: hidden; + transition: transform .16s ease-out, visibility 0s linear .16s; + } + .workstation.conversation-workstation > .navigation-rail[data-open="true"] { + transform: none; + visibility: visible; + transition: transform .16s ease-out; + } + .nav-drawer-backdrop { display: block; position: fixed; inset: 0; z-index: 15; background: #0b0f14b8; } + .workstation-topbar .nav-drawer-button { display: inline-flex; flex: 0 0 auto; } + /* The drawer is wide enough for whole names; a long one takes a second line instead of being cut. */ + .conversation-workstation .navigation-rail .project-button strong, + .conversation-workstation .navigation-rail .thread-button strong { max-width: none; white-space: normal; overflow-wrap: anywhere; } + .conversation-workstation .navigation-rail .project-button > .bp6-button-text, + .conversation-workstation .navigation-rail .thread-button > .bp6-button-text { text-align: left; } + + /* Top bar: the list button, the project name, and what needs attention. */ + .conversation-workstation .workstation-topbar { gap: 8px; padding: 0 12px; } + .workstation-topbar .context-path, + .workstation-topbar .header-status { gap: 8px; } + /* A working connection needs no tag here; a failed one keeps its words. */ + .workstation-topbar .header-health:not([data-health="failed"]) { display: none; } + .context-policy { display: none; } + + /* Tabs: when they do not fit, this row scrolls sideways and the page does not. */ + .conversation-workstation .workspace-navigation { padding: 0 12px; } + .workspace-navigation .bp6-tabs { flex: 1 1 auto; min-width: 0; } + .workspace-navigation .bp6-tab-list { gap: 22px; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; } + .workspace-navigation .bp6-tab { flex: 0 0 auto; } + .workspace-counts { display: none; } + + /* Conversation */ + .conversation-heading { padding: 12px 16px; } + .conversation-timeline { padding: 18px 14px; } + .user-message { max-width: 100%; padding: 12px; } + .composer-dock { padding: 8px; } + .composer-tools { gap: 6px; } + .model-settings.compact select { max-width: 112px; } + .usage-summary { text-align: left; font-size: 9px; } + .legacy-thread-empty { padding: 36px 12px 28px; } + .legacy-thread-empty h1 { font-size: 21px; } + .legacy-thread-actions > .bp6-button { flex: 1; min-width: 150px; } +} +@media (max-width: 760px) and (prefers-reduced-motion: reduce) { + .workstation.conversation-workstation > .navigation-rail, + .workstation.conversation-workstation > .navigation-rail[data-open="true"] { transition: none; } +} @media(max-width:700px){.followup-grid,.followup-detail-columns{grid-template-columns:1fr}.review-items{grid-template-columns:1fr}} -@media(max-width:480px){.conversation-workstation{grid-template-columns:110px minmax(0,1fr)}.conversation-workstation .brand-mark{display:none}.conversation-workstation .brand strong{font-size:12px}.conversation-workstation .thread-button strong,.conversation-workstation .project-button strong{max-width:72px}.conversation-heading h1{font-size:12px}.conversation-empty h1{font-size:21px}.user-message time{display:none}.composer-tools>.bp6-button{font-size:11px}.workstation-topbar .header-status .bp6-tag{display:none}.workspace-navigation .bp6-tab-list{gap:12px}.model-settings.compact label{font-size:9px}} +@media (max-width: 480px) { + /* The setup notice folds to its warning icon; its words stay for screen readers and in the tooltip. */ + .workstation-topbar .setup-needed-button > .bp6-button-text { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + .workstation-topbar .setup-needed-button > .bp6-icon:last-child { display: none; } + .workspace-navigation .bp6-tab-list { gap: 12px; } + .conversation-heading h1 { font-size: 12px; } + .conversation-empty h1 { font-size: 21px; } + .user-message time { display: none; } + .composer-tools > .bp6-button { font-size: 11px; } + .model-settings.compact label { font-size: 9px; } +} @media(max-width:480px){.result-example-page{padding:18px 0;overflow-x:hidden}.result-example-page>.bp6-callout{width:calc(100vw - 28px);margin:0 auto 22px}.result-example-page>.conversation-timeline{width:calc(100vw - 28px);max-width:calc(100vw - 28px);padding:18px 0}.result-example-page .user-message{max-width:100%;margin-left:0}.result-example-page .followup-summary{padding:12px}} @@ -238,9 +372,18 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli .first-run-providers, .first-run-choices { display: grid; gap: 10px; } .first-run-provider, .first-run-choice { display: grid; gap: 8px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 8px; background: #1a222c; text-align: left; color: inherit; } .first-run-provider { grid-template-columns: minmax(0,1fr); gap: 10px; padding: 16px 18px; } -.first-run-provider-head { display: flex; align-items: center; gap: 10px; min-width: 0; } -.provider-mark { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: #26384c; color: #cfe1ff; font-weight: 700; font-size: 13px; } -.provider-key-toggle { margin-left: auto; } +.first-run-provider-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-width: 0; } +/* The state tag is never shortened ("연결 안 됨" has to be read whole); when the row is full the key button takes the next line. */ +.first-run-provider-head > .bp6-tag { flex: 0 0 auto; } +/* A dot in the provider's own colour next to its name. The first letter did not tell ChatGPT from Claude. */ +.provider-mark { flex: 0 0 auto; width: 10px; height: 10px; border-radius: 50%; background: var(--muted); } +.provider-mark[data-provider="openai"] { background: #19c37d; } +.provider-mark[data-provider="anthropic"] { background: #d97757; } +.provider-mark[data-provider="xai"] { background: #e8eaed; } +/* The sentence takes the room that is left; the two ways on stay side by side. */ +.first-run-footer > span:first-child { flex: 1 1 260px; } +.first-run-footer-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; } +.provider-key-toggle { margin-left: auto; flex: 0 0 auto; white-space: nowrap; } .first-run-provider .first-run-provider-summary { margin: 0; color: #c5cfdb; font-size: 13px; line-height: 1.6; } .first-run-provider-body { display: grid; gap: 8px; } .oauth-login-flow, .xai-device-login { display: grid; gap: 8px; justify-items: start; } @@ -364,10 +507,11 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli .trace-evidence-dialog .evidence-item.focused{border-color:#8abbff;background:rgba(76,144,240,.16);box-shadow:0 0 0 2px rgba(76,144,240,.45)} .trace-import-dialog{width:min(720px,94vw)} .conversation-synthetic-notice{flex:none;margin:0 0 8px;padding:4px 10px;min-height:0;background:#3a2a10 !important;font-size:12px} -.archived-project-row{display:grid;gap:2px;min-width:0;font-size:12px} +/* An archived project is one row, like an open one: the name, then its menu. */ +.archived-project-row { display: flex; align-items: center; gap: 2px; min-width: 0; min-height: 30px; padding-left: 8px; font-size: 12px; color: var(--muted); } +.archived-project-row .bp6-popover-target > .bp6-button { color: var(--muted); } .local-retrieval-command{display:block;margin:4px 0;padding:6px 8px;border:1px solid var(--line-soft);border-radius:3px;background:var(--surface-2);font-family:var(--code-font);font-variant-ligatures:none;font-size:11px;line-height:1.5;overflow-wrap:anywhere;user-select:all} -.archived-project-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.archived-project-actions{display:flex;flex-wrap:wrap;align-items:center;gap:2px} +.archived-project-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Languages are chosen in Settings; the question box only shows a fixed response language. */ .language-card .language-row { display:flex; align-items:center; flex-wrap:wrap; gap:8px 12px; margin:8px 0 2px; } @@ -509,3 +653,35 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli clip-path: inset(50%); white-space: nowrap; } + +/* ---- Settings ---- */ +/* On the settings screen the notice about a saved model takes its own line above the two choices. */ +.model-settings:not(.compact) > .result-notice { flex-basis: 100%; margin: 0; } +.settings-no-model { margin-bottom: 12px; } +.settings-no-model .bp6-button { margin-left: 8px; } +.history-toolbar > .project-review-empty { margin: 0; } + +/* ---- New project ---- */ +.project-create { max-width: 760px; } +.start-page.project-create h1 { margin: 8px 0 6px; font-size: 27px; line-height: 1.3; } +.project-create > .muted { margin: 0 0 18px; font-size: 13px; } +.project-create .onboarding-card > .bp6-button { margin-top: 4px; } + +/* ---- Sources page ---- */ +.workspace-heading > .bp6-button { flex: 0 0 auto; white-space: nowrap; } +.connected-sources { margin-bottom: 20px; } +.connected-sources > h2 { margin: 0 0 12px; } +.connected-sources > .empty-copy { padding-left: 0; } +/* The connect button sits on the line of the choice next to it, not stretched over the label above it. */ +.workstation .source-controls > .bp6-button { align-self: end; } +.source-scope-advanced .source-control-label { max-width: 360px; } +.source-scope-advanced .muted { margin: 8px 0 0; max-width: 64ch; } +.web-setup-denied { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; color: var(--muted); font-size: 12px; } +@media (max-width: 480px) { + .workspace-heading { flex-wrap: wrap; gap: 8px; } +} + +/* Korean lines break between words, not inside them. A word longer than its line still breaks. */ +html:lang(ko) { word-break: keep-all; overflow-wrap: break-word; } +/* Text marked as another language inside a Korean screen (an answer in Japanese, for one) keeps its own rule. */ +[lang]:not(:lang(ko)) { word-break: normal; } diff --git a/config/complexity-budget.json b/config/complexity-budget.json index e0494b10..9411a7c3 100644 --- a/config/complexity-budget.json +++ b/config/complexity-budget.json @@ -44,20 +44,19 @@ "apps/web/src/api/resultEvidenceIdentity.ts::currentResultDigest": 22, "apps/web/src/components/CapabilityWorkbench.tsx::MethodDetail": 23, "apps/web/src/components/ConversationTimeline.tsx::ConversationTimeline": 50, - "apps/web/src/components/FirstRunSetup.tsx::FirstRunSetup": 55, + "apps/web/src/components/FirstRunSetup.tsx::FirstRunSetup": 54, "apps/web/src/components/FirstRunSetup.tsx::FirstRunSetup.": 47, "apps/web/src/components/HypothesisCompare.tsx::HypothesisCompare.": 23, "apps/web/src/components/LinkRecheckDialog.tsx::LinkRecheckDialog": 23, - "apps/web/src/components/LiveProjectWorkspace.tsx::LiveProjectWorkspace": 69, + "apps/web/src/components/LiveProjectWorkspace.tsx::LiveProjectWorkspace": 53, "apps/web/src/components/LiveProjectWorkspace.tsx::ProjectSession": 57, - "apps/web/src/components/LocalRetrievalSettingsCard.tsx::LocalRetrievalSettingsCard": 29, + "apps/web/src/components/LocalRetrievalSettingsCard.tsx::LocalRetrievalSettingsCard": 27, "apps/web/src/components/LocalRetrievalSettingsCard.tsx::ProjectSection": 22, "apps/web/src/components/ModelCredentialPanel.tsx::CompanyRow": 40, "apps/web/src/components/ModelSettings.tsx::ModelSettings": 82, "apps/web/src/components/OAuthLoginFlow.tsx::OAuthLoginFlow": 61, "apps/web/src/components/OAuthLoginFlow.tsx::OAuthLoginFlow.": 26, "apps/web/src/components/OAuthLoginFlow.tsx::readSession": 22, - "apps/web/src/components/ProjectWebSetup.tsx::ProjectWebSetup": 23, "apps/web/src/components/ResearchActivityStrip.tsx::ActivityDetails": 30, "apps/web/src/components/ResearchComposer.tsx::ResearchComposer": 52, "apps/web/src/components/ResearchDetailPane.tsx::ResearchDetailPane": 29, @@ -70,7 +69,7 @@ "apps/web/src/components/history/DecisionDeltaComparison.tsx::DecisionDeltaView": 26, "apps/web/src/components/history/HistoryContent.tsx::HistoryContent": 21, "apps/web/src/components/history/HistoryDetail.tsx::HistoryDetail": 48, - "apps/web/src/components/history/ResearchHistoryWorkspace.tsx::ScopedHistory": 43, + "apps/web/src/components/history/ResearchHistoryWorkspace.tsx::ScopedHistory": 29, "apps/web/src/components/history/RestorePreviewDialog.tsx::RestorePreviewDialog": 37, "apps/web/src/components/history/useRestoreFlow.ts::useRestoreFlow.run": 34, "apps/web/src/components/modelCredentialPresentation.ts::credentialAccountLabel": 33, diff --git a/src/thoth/adapters/http/file_staging.py b/src/thoth/adapters/http/file_staging.py index 770197ad..e6c15bcc 100644 --- a/src/thoth/adapters/http/file_staging.py +++ b/src/thoth/adapters/http/file_staging.py @@ -22,6 +22,9 @@ from thoth.protocol.jsonrpc import RpcApplicationError MAX_UPLOAD_BYTES = 64 * 1024 * 1024 +# A refused upload names its reason in this response header. The sentence in the body stays as it +# was for programs that read it; the code lets the screen say the reason in its own language. +UPLOAD_ERROR_HEADER = "x-thoth-upload-error" MEDIA_TYPES = { ".html": "text/html", ".htm": "text/html", @@ -64,6 +67,12 @@ def safe_upload_name(filename: str, suffix: str) -> str: return f"{safe_stem}{suffix}" +def _refused(status_code: int, detail: str, code: str) -> HTTPException: + return HTTPException( + status_code=status_code, detail=detail, headers={UPLOAD_ERROR_HEADER: code} + ) + + async def stage_file_upload( auth: HttpAuthenticationPort | None, request: Request, @@ -93,12 +102,12 @@ async def stage_file_upload( suffix = Path(filename).suffix.lower() media_type = MEDIA_TYPES.get(suffix) if media_type is None: - raise HTTPException(status_code=415, detail="unsupported research file type") + raise _refused(415, "unsupported research file type", "UPLOAD_UNSUPPORTED_TYPE") payload = await file.read(MAX_UPLOAD_BYTES + 1) if not payload: - raise HTTPException(status_code=400, detail="empty research file") + raise _refused(400, "empty research file", "UPLOAD_EMPTY") if len(payload) > MAX_UPLOAD_BYTES: - raise HTTPException(status_code=413, detail="research file exceeds 64 MiB") + raise _refused(413, "research file exceeds 64 MiB", "UPLOAD_TOO_LARGE") if hosted_mode(): try: reject_if_upload_exceeds( diff --git a/src/thoth/application/services/history_change_summary.py b/src/thoth/application/services/history_change_summary.py index 8f4ff503..f4f41ddf 100644 --- a/src/thoth/application/services/history_change_summary.py +++ b/src/thoth/application/services/history_change_summary.py @@ -35,7 +35,7 @@ "risk_tier": "위험 등급", "scope": "적용 조건", "cutoff_at": "기준시점", - "authority_status": "출처 권위", + "authority_status": "자료 성격", "support_status": "근거 충분성", "assertion": "기억 내용", "transition": "검토 결과", diff --git a/tests/contract/test_file_upload.py b/tests/contract/test_file_upload.py index 6ed749ee..b6ed969c 100644 --- a/tests/contract/test_file_upload.py +++ b/tests/contract/test_file_upload.py @@ -1,11 +1,13 @@ from __future__ import annotations +import re from pathlib import Path import httpx import pytest from thoth.adapters.http.app import create_app +from thoth.adapters.http.file_staging import MAX_UPLOAD_BYTES, MEDIA_TYPES, UPLOAD_ERROR_HEADER @pytest.mark.asyncio @@ -70,6 +72,46 @@ async def test_file_stage_rejects_unsupported_type( ) assert response.status_code == 415 + # The sentence is unchanged; the reason also travels as a code the screen can translate. + assert response.json() == {"detail": "unsupported research file type"} + assert response.headers["x-thoth-upload-error"] == "UPLOAD_UNSUPPORTED_TYPE" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("size", "status", "detail", "code"), + [ + (0, 400, "empty research file", "UPLOAD_EMPTY"), + (MAX_UPLOAD_BYTES + 1, 413, "research file exceeds 64 MiB", "UPLOAD_TOO_LARGE"), + ], +) +async def test_file_stage_names_the_reason_for_an_empty_or_oversized_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, size: int, status: int, detail: str, code: str +) -> None: + monkeypatch.setenv("THOTH_WORKSPACE", str(tmp_path / "workspace")) + transport = httpx.ASGITransport(app=create_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8765") as client: + response = await client.post( + "/files/stage", + files={"file": ("notes.md", b"x" * size, "text/markdown")}, + headers={"x-thoth-project-id": "project:file-upload"}, + ) + + assert (response.status_code, response.json()) == (status, {"detail": detail}) + assert response.headers[UPLOAD_ERROR_HEADER] == code + + +def test_the_file_picker_offers_exactly_the_types_the_server_stages() -> None: + """The screen filters the file dialog and refuses a wrong type before sending it. Its list is a + copy of the server's, so the two are compared here.""" + source = ( + Path(__file__).parents[2] / "apps" / "web" / "src" / "api" / "uploadTypes.ts" + ).read_text(encoding="utf-8") + listed = re.search(r"UPLOAD_EXTENSIONS = \[([^\]]*)\]", source) + limit = re.search(r"MAX_UPLOAD_BYTES = (\d+) \* 1024 \* 1024;", source) + assert listed is not None and limit is not None + assert sorted(re.findall(r'"(\.[a-z]+)"', listed.group(1))) == sorted(MEDIA_TYPES) + assert int(limit.group(1)) * 1024 * 1024 == MAX_UPLOAD_BYTES @pytest.mark.asyncio diff --git a/tests/unit/services/test_history_change_summary.py b/tests/unit/services/test_history_change_summary.py index 9f601e16..b4bbc391 100644 --- a/tests/unit/services/test_history_change_summary.py +++ b/tests/unit/services/test_history_change_summary.py @@ -58,6 +58,12 @@ def test_status_authority_and_time_changes_are_flagged() -> None: assert (changed(cutoff_at="2026-09-15T00:00:00Z") or object()).flags == ("TIME",) # type: ignore[attr-defined] +def test_the_kind_of_a_source_has_the_same_name_as_on_the_upload_form() -> None: + summary = changed(authority_status="OFFICIAL") + assert summary is not None + assert [line.label for line in summary.lines] == ["자료 성격"] + + def test_an_unlisted_field_is_counted_as_other_and_its_raw_name_stays_in_technical_info() -> None: summary = summarize_change({"scope_note": "a"}, {"scope_note": "b"}) assert summary is not None and summary.flags == () and summary.lines == ()