From 9887f6957e0e28dd240163f11179415627794820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valent=C3=ADn=20Quiroz=20de=20la=20Sierra?= <85706426+vqsierra@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:37:15 -0500 Subject: [PATCH] Console wording, no browser agent tools, and a roadmap that matches the app - The console asked every researcher to keep the second copy of the key file in their Teams channel files. It now asks for a secure place the study has approved. - Removed lib/board-tools.ts, which registered tools on document.modelContext so that an AI agent in the participant's browser could read the cards and piles and move cards. Nothing else used it. - ROADMAP listed three workflows, contribution review and Sites hosting as working, and a DOI as still to do. It now describes this release. --- app/components/console/console-page.tsx | 4 +- app/components/console/publish-steps.tsx | 12 +- app/components/console/session-view.tsx | 2 +- app/components/participant/sort-workspace.tsx | 4 - app/lib/board-tools.ts | 112 ------------------ app/tests/console-smoke.mjs | 2 +- docs/ROADMAP.md | 15 +-- 7 files changed, 18 insertions(+), 133 deletions(-) delete mode 100644 app/lib/board-tools.ts diff --git a/app/components/console/console-page.tsx b/app/components/console/console-page.tsx index b368d70..ff4101f 100644 --- a/app/components/console/console-page.tsx +++ b/app/components/console/console-page.tsx @@ -88,8 +88,8 @@ export default function ConsolePage() {

- The key file can open every response in its session. Keep it on this computer and in your study team's - Teams channel files only. + The key file can open every response in its session. Keep it on this computer and in one secure place your + study has approved for its files, and nowhere else.

diff --git a/app/components/console/publish-steps.tsx b/app/components/console/publish-steps.tsx index fda205e..ed098d6 100644 --- a/app/components/console/publish-steps.tsx +++ b/app/components/console/publish-steps.tsx @@ -17,7 +17,7 @@ import { useUnsavedGuard } from "./use-unsaved-guard"; // Save the key file (twice), then publish. Publishing is safe to retry: the request is rebuilt from the file. export default function PublishSteps({ file, onPublished }: { file: SessionFile; onPublished: () => void }) { const [saved, setSaved] = useState(false); - const [teamsCopy, setTeamsCopy] = useState(false); + const [secondCopy, setSecondCopy] = useState(false); const [creationKey, setCreationKey] = useState(() => readKey(CREATION_KEY_STORAGE) ?? ""); const [remember, setRemember] = useState(true); const [busy, setBusy] = useState(false); @@ -71,9 +71,9 @@ export default function PublishSteps({ file, onPublished }: { file: SessionFile; setTeamsCopy(e.target.checked)} - label="I also saved a copy in our study team's Teams channel files." + checked={secondCopy} + onChange={(e) => setSecondCopy(e.target.checked)} + label="I also saved a second copy in a secure place our study has approved for its files." />
@@ -94,11 +94,11 @@ export default function PublishSteps({ file, onPublished }: { file: SessionFile; />
-
- {(!saved || !teamsCopy) &&

Save the key file and its second copy first.

} + {(!saved || !secondCopy) &&

Save the key file and its second copy first.

} diff --git a/app/components/console/session-view.tsx b/app/components/console/session-view.tsx index baf0509..0cd913d 100644 --- a/app/components/console/session-view.tsx +++ b/app/components/console/session-view.tsx @@ -120,7 +120,7 @@ export default function SessionView({
- The participant list changed. Save the updated key file, and replace the copy in Teams. + The participant list changed. Save the updated key file, and replace your second copy with it. diff --git a/app/components/participant/sort-workspace.tsx b/app/components/participant/sort-workspace.tsx index cad3c8c..21d90aa 100644 --- a/app/components/participant/sort-workspace.tsx +++ b/app/components/participant/sort-workspace.tsx @@ -12,7 +12,6 @@ import { Content, Header } from "@jetbrains/ring-ui-built/components/island/isla import ProgressBar from "@jetbrains/ring-ui-built/components/progress-bar/progress-bar"; import Text from "@jetbrains/ring-ui-built/components/text/text"; import { ConfirmDialog, ErrorMessage } from "@malverdesoftware/ui/controls"; -import { useBoardTools } from "@/lib/board-tools"; import { checkResponse, orderCards, type SortDefinition } from "@/lib/session"; import type { ResponseBody } from "@/lib/model"; import SortBoard, { count, type BoardView } from "./sort-board"; @@ -43,9 +42,6 @@ export default function SortWorkspace({ session, definition, initial, seed, onSu const { state, saveNow } = useAutosave(session, body, !busy && !closed); const editable = !busy && !closed && state !== "closed" && state !== "conflict"; const cards = useMemo(() => orderCards(definition.cards, definition.randomize, seed), [definition, seed]); - const move = (card: string, pile: string | null) => - setBody((b) => ({ ...b, assignments: { ...b.assignments, [card]: pile } })); - useBoardTools(definition.cards, body, editable, move); const assigned = cards.filter((c) => body.assignments[c.id]).length; async function submit() { diff --git a/app/lib/board-tools.ts b/app/lib/board-tools.ts deleted file mode 100644 index 8e0b321..0000000 --- a/app/lib/board-tools.ts +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; -import { useEffect, useLayoutEffect, useRef } from "react"; -import { flushSync } from "react-dom"; -import type { Card, ResponseBody } from "./model"; -type Tool = { - name: string; - title: string; - description: string; - inputSchema: object; - annotations: { readOnlyHint: boolean; untrustedContentHint: boolean }; - execute: (input: unknown) => unknown; -}; -type Context = { - registerTool: ( - tool: Tool, - options: { signal: AbortSignal }, - ) => void | Promise; -}; -export function useBoardTools( - cards: Card[], - body: ResponseBody, - editable: boolean, - onMove: (cardId: string, pileId: string | null) => void, -) { - const state = useRef({ cards, body, editable, onMove }); - useLayoutEffect(() => { - state.current = { cards, body, editable, onMove }; - }, [cards, body, editable, onMove]); - useEffect(() => { - const context = (document as Document & { modelContext?: Context }) - .modelContext; - if (!context?.registerTool) return; - const lifecycle = new AbortController(); - const tools: Tool[] = [ - { - name: "get_current_sort", - title: "Read the current card sort", - description: - "Read the visible participant card set, piles, and assignments. Does not read other participants, access keys, or facilitator results.", - inputSchema: { - type: "object", - properties: {}, - additionalProperties: false, - }, - annotations: { readOnlyHint: true, untrustedContentHint: true }, - execute(input) { - if (!input || typeof input !== "object" || Object.keys(input).length) - throw new Error("Expected an empty object."); - const s = state.current; - return { - cards: s.cards, - piles: s.body.piles, - assignments: s.body.assignments, - editable: s.editable, - }; - }, - }, - { - name: "move_card_to_pile", - title: "Move a card in this response", - description: - "Move one visible card into an existing participant-created pile, or leave it unsorted using null. Updates the same board as the menu and autosaves a draft. Does not submit the response.", - inputSchema: { - type: "object", - properties: { - cardId: { type: "string" }, - pileId: { type: ["string", "null"] }, - }, - required: ["cardId", "pileId"], - additionalProperties: false, - }, - annotations: { readOnlyHint: false, untrustedContentHint: false }, - execute(input) { - if (!input || typeof input !== "object") - throw new Error("Expected cardId and pileId."); - const i = input as { cardId?: unknown; pileId?: unknown }; - if ( - Object.keys(i).some((k) => k !== "cardId" && k !== "pileId") || - typeof i.cardId !== "string" || - !(typeof i.pileId === "string" || i.pileId === null) - ) - throw new Error("Expected cardId and a pileId or null."); - const s = state.current; - if (!s.editable) - throw new Error("This response is not open for editing."); - if ( - !s.cards.some((c) => c.id === i.cardId) || - !(i.pileId === null || s.body.piles.some((p) => p.id === i.pileId)) - ) - throw new Error("The card or pile is not on this board."); - flushSync(() => - s.onMove(i.cardId as string, i.pileId as string | null), - ); - return { - cardId: i.cardId, - pileId: i.pileId, - saved: false, - message: "Board updated; draft save is pending.", - }; - }, - }, - ]; - for (const tool of tools) { - try { - void Promise.resolve( - context.registerTool(tool, { signal: lifecycle.signal }), - ).catch(() => {}); - } catch {} - } - return () => lifecycle.abort(); - }, []); -} diff --git a/app/tests/console-smoke.mjs b/app/tests/console-smoke.mjs index 7d4e79d..78ce09b 100644 --- a/app/tests/console-smoke.mjs +++ b/app/tests/console-smoke.mjs @@ -51,7 +51,7 @@ try { const savedKeyFile = await download(keyFile); const file = parseSessionFile(readFileSync(savedKeyFile, "utf8")); assert.deepEqual(file.participants.map((p) => p.label), ["Y01", "Y02"]); - await tap("label", "Teams channel files"); + await tap("label", "saved a second copy"); await type('input[type="password"]', creationKey); await screenshot("console-prepared"); await tap("button", "Publish the session"); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8027208..1b98c95 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,22 +2,23 @@ ## Working in 0.1 -- Text cards, CSV import, and three configurable workflows -- Separate links for each participant, with saved drafts and final submissions +- Text cards, typed in or imported from a CSV, and one activity: each participant sorts the same set of cards on their own +- A personal link and six-digit passcode for each participant, with saved drafts and one final submission - Participant-defined piles, optional explanations, and unsorted cards -- Contribution review and a fixed shared set of cards, with where each card came from +- Responses sealed in the participant's browser, which only the researcher's key file can open; the site stores sealed data and deletes it when the session is deleted or expires +- A researcher console that runs on the researcher's own computer: create a session, follow progress, add or remove participants, collect, close and delete - CSV and JSON exports, a co-sorting matrix, a circular connection map, and average-linkage clustering -- Local development, a standalone Cloudflare deployment setup, and Sites hosting +- Local development and a standalone Cloudflare deployment setup ## Next - Pilot with synthetic data and a small group of community members and researchers +- Bring back the card-generation and collect-review-sort activities described in [methods](METHODS.md), with contribution review and a record of where each card came from - Decide whether participants need to correct or withdraw a submission afterward -- Add saved review drafts for facilitators, access-key rotation and recovery, and study deletion +- Add key rotation and recovery - Add optional verified invitation tokens and deployment-level abuse controls for large public use - Try collaborative live sorting as its own mode, clearly separate from independent responses - Try researcher-reviewed category crosswalks and optional multidimensional scaling, with documented stress diagnostics - Try a multilingual interface, offline facilitation, images and audio, and more accessibility testing -- Mint a DOI at the first public release and add it to NOTICE, CITATION.cff and the README (the license, funding acknowledgment and `CITATION.cff` were added 2026-11-12) -None of these are in the current app. +None of the items under Next are in the current app.