From 30dea9feae0d6435f2d7ec0831b426fe8a0f85fc Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 16 Aug 2026 05:11:59 -0400 Subject: [PATCH 1/4] feat(question): keep questions open until answered and return labels Questions no longer expire after 60 seconds. An expired lease is no longer reported as a user dismissal, answers carry the question text and the option labels the user saw, and Escape no longer dismisses an open question. --- .changeset/question-lease-and-labels.md | 5 + apps/pythinker-web/AGENTS.md | 8 +- .../src/components/QuestionCard.vue | 41 +++++-- .../src/composables/usePythinkerWebClient.ts | 1 + .../src/i18n/locales/en/question.ts | 2 + apps/pythinker-web/src/types.ts | 1 + .../test/question-card-lifecycle.test.ts | 115 ++++++++++++++++++ .../test/question-card-recommended.test.ts | 1 + packages/agent-core/src/errors/codes.ts | 7 ++ .../src/services/question/question.ts | 63 +++++----- .../tools/builtin/collaboration/ask-user.ts | 20 ++- .../test/services/question-adapter.test.ts | 102 ++++++++++------ .../agent-core/test/tools/ask-user.test.ts | 34 ++++-- packages/protocol/src/events.ts | 1 + packages/server/src/routes/questions.ts | 15 ++- .../src/services/question/questionService.ts | 31 +++-- packages/server/test/question.e2e.test.ts | 51 ++++++-- packages/server/test/services.test.ts | 5 +- 18 files changed, 386 insertions(+), 117 deletions(-) create mode 100644 .changeset/question-lease-and-labels.md create mode 100644 apps/pythinker-web/test/question-card-lifecycle.test.ts diff --git a/.changeset/question-lease-and-labels.md b/.changeset/question-lease-and-labels.md new file mode 100644 index 00000000..a5dd49e0 --- /dev/null +++ b/.changeset/question-lease-and-labels.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': patch +--- + +Questions no longer expire after 60 seconds, expired questions are not reported as user dismissals, answers retain question text and option labels, and Escape no longer dismisses a question. diff --git a/apps/pythinker-web/AGENTS.md b/apps/pythinker-web/AGENTS.md index 2f7e0784..7206cfba 100644 --- a/apps/pythinker-web/AGENTS.md +++ b/apps/pythinker-web/AGENTS.md @@ -23,13 +23,13 @@ The browser web UI for Pythinker Code — a peer to the TUI in `apps/pythinker-c - Shared components go in `src/components/`; reusable logic goes in `src/composables/` with a `use` prefix. - There is **no auto-import plugin** and **no path alias** — `#/` and `@/` are intentionally unused. Write relative imports (`../i18n`, `./config`). -## i18n (normative — keeping locales in sync is manual) +## i18n (normative — the app is English-only) - Setup: `src/i18n/index.ts`, vue-i18n in Composition mode (`legacy: false`), fallback `en`. The active locale is persisted in `localStorage` under `pythinker-locale`. -- Locale files: `src/i18n/locales/{en,zh}/.ts`, each `export default { ... } as const`. New namespaces are registered in `src/i18n/locales/index.ts`. +- **`en` is the only locale.** `src/i18n/locales/` contains exactly one directory, and `locales/index.ts` registers only `en`. Do not add a second locale, and do not "restore parity" with one that does not exist. +- Locale files: `src/i18n/locales/en/.ts`, each `export default { ... } as const`. New namespaces are registered in `src/i18n/locales/index.ts`. - Reference with `const { t } = useI18n()` and `t('namespace.key')` (same form in templates). -- **Adding a key:** add it to **both** `en/.ts` and `zh/.ts`. **Adding a namespace:** create the file in both locales **and** register it in `locales/index.ts`. -- There is **no automated missing-key or en/zh parity check**. Keeping the two locales in sync is a manual responsibility — do not leave a key present in only one locale. +- **Adding a key:** add it to `en/.ts`. **Adding a namespace:** create the file under `en/` **and** register it in `locales/index.ts`. ## Commands diff --git a/apps/pythinker-web/src/components/QuestionCard.vue b/apps/pythinker-web/src/components/QuestionCard.vue index 829b4aeb..06eea682 100644 --- a/apps/pythinker-web/src/components/QuestionCard.vue +++ b/apps/pythinker-web/src/components/QuestionCard.vue @@ -30,6 +30,22 @@ const total = computed(() => props.question.questions.length); const hasPreview = computed(() => current.value.options.some((option) => option.preview?.trim()), ); +const now = ref(Date.now()); +const remainingMinutes = computed(() => { + const expiresAt = Date.parse(props.question.expiresAt); + if (Number.isNaN(expiresAt)) return undefined; + return Math.ceil((expiresAt - now.value) / 60_000); +}); +const leaseWarning = computed(() => { + const expiresAt = Date.parse(props.question.expiresAt); + if (Number.isNaN(expiresAt)) return undefined; + const remainingMs = expiresAt - now.value; + if (remainingMs <= 0 || remainingMs >= 5 * 60_000) return undefined; + if (remainingMs < 60_000) return t('question.expiresSoonSeconds'); + const minutes = remainingMinutes.value; + if (minutes === undefined) return undefined; + return t('question.expiresSoon', { minutes }); +}); function goBack(): void { if (step.value > 0) step.value--; @@ -207,17 +223,15 @@ function dismiss(): void { } // --------------------------------------------------------------------------- -// Keyboard: number keys pick options for current question, Enter submit, Esc dismiss +// Keyboard: number keys pick options for the current question and Enter submits. // --------------------------------------------------------------------------- function handleKeydown(e: KeyboardEvent): void { const tag = (document.activeElement?.tagName ?? '').toLowerCase(); if (tag === 'input' || tag === 'textarea') return; - // While minimized the options aren't visible, so don't let number keys pick - // an unseen answer; only Escape (dismiss) stays live. - if (minimized.value && e.key !== 'Escape') return; + // While minimized the options are not visible, so keyboard selection is disabled. + if (minimized.value) return; - if (e.key === 'Escape') { e.preventDefault(); dismiss(); return; } if (e.key === 'Enter') { e.preventDefault(); submit(); return; } const num = parseInt(e.key, 10); @@ -236,8 +250,19 @@ function handleKeydown(e: KeyboardEvent): void { } } -onMounted(() => document.addEventListener('keydown', handleKeydown)); -onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); +let leaseTimer: ReturnType | undefined; + +onMounted(() => { + document.addEventListener('keydown', handleKeydown); + leaseTimer = setInterval(() => { + now.value = Date.now(); + }, 30_000); +}); + +onUnmounted(() => { + document.removeEventListener('keydown', handleKeydown); + if (leaseTimer !== undefined) clearInterval(leaseTimer); +});