2026-05-08-speaking-record-start-work.md 13 KB

Speaking — Record Start as Course-Work Entry — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: When a student clicks "开始对话" on a tool-77 (English-Speaking) frame and a fresh sessionId is created, fire addCourseWorks_workPage once with content = sessionId so the teacher dashboard sees the student has begun this slide.

Architecture: Add a new recordSpeakingStart(sessionId) provider in Student/index.vue (which has access to props.userid, props.courseid, and slideIndex). TopicDiscussionPreview.vue injects it and calls it from the fresh-start path inside startDialogue() only — never on resume, completion, or in teacher mode. Failure is fire-and-forget; idempotency is enforced purely by the call-site rule.

Tech Stack: Vue 3 (Composition API, <script setup>), TypeScript, Pinia, existing api.submitWork wrapper at src/services/course.ts:45.

Spec: docs/superpowers/specs/2026-05-08-speaking-record-start-work-design.md


File Map

File Change
src/views/Student/index.vue Add provide('recordSpeakingStart', ...) next to existing provide('notifySpeakingProgress', ...) (around line 583).
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue Add inject('recordSpeakingStart', ...) next to existing inject('notifySpeakingProgress', ...) (around line 114), then call it inside startDialogue() immediately after the existing notifySpeakingProgress('active', ...) (around line 333).

src/views/Student/index2.vue does not need changes — verified that it does not provide notifySpeakingProgress and the speaking flow does not route through it.

No new files, no test files (this codebase has no unit-test infrastructure for the affected Vue components — verification is manual; see Task 3).


Task 1: Provide recordSpeakingStart in Student/index.vue

Files:

  • Modify: src/views/Student/index.vue (insert a new provide(...) block immediately after the existing notifySpeakingProgress block at lines 583-593)

Context for the engineer:

  • api is the default import from @/services/course (already imported at line 422). It exposes api.submitWork({uid, cid, stage, task, tool, atool, content, type}) which POSTs to addCourseWorks_workPage.
  • slideIndex is from storeToRefs(slidesStore) (already destructured at line 485). Use slideIndex.value to read the current value.
  • props.userid, props.courseid, and props.type exist on this component. props.type === '2' means student client (type === '1' is teacher).
  • The existing pattern at Student/index.vue:1883 (submitWork(slideIndex, atool, content, type)) shows the convention: stage='0', tool='0', task=String(slideIndex). Follow it exactly.

  • [ ] Step 1: Insert the new provider

Find the block at src/views/Student/index.vue:583-593:

provide('notifySpeakingProgress', (status: 'active' | 'completed', payload: { configId: string; sessionId: string }) => {
  if (props.type !== '2') return // 只有学生客户端发广播
  sendMessage({
    type: 'speaking_session_updated',
    courseid: props.courseid,
    slideIndex: slideIndex.value,
    userid: props.userid,
    status,
    ...payload,
  })
})

Insert this immediately after that block (still at the top level of <script setup>, same indentation):

provide('recordSpeakingStart', async (sessionId: string) => {
  if (props.type !== '2') return // 只有学生客户端写作业记录
  if (!sessionId || !props.courseid || !props.userid) return
  try {
    await api.submitWork({
      uid: props.userid as string,
      cid: props.courseid as string,
      stage: '0',
      task: String(slideIndex.value),
      tool: '0',
      atool: '77',
      content: sessionId,
      type: '21',
    })
  }
  catch (err) {
    console.error('[speaking] recordSpeakingStart failed:', err)
  }
})
  • Step 2: Type-check

Run: cd /Users/buoy/Development/gitrepo/PPT && npm run type-check Expected: PASS (no new TS errors). If the codebase already has pre-existing errors, confirm none of them are inside Student/index.vue and none reference recordSpeakingStart or submitWork.

  • Step 3: Commit
git -C /Users/buoy/Development/gitrepo/PPT add src/views/Student/index.vue
git -C /Users/buoy/Development/gitrepo/PPT commit -m "$(cat <<'EOF'
feat(speaking): provide recordSpeakingStart for course-work entry

Adds a provider that posts addCourseWorks_workPage once with
content=sessionId, atool='77', type='21' for the student client only.
Failure is logged and swallowed; the dialogue is not blocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2: Inject and call from startDialogue() in TopicDiscussionPreview.vue

Files:

  • Modify: src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue (add inject around line 114; add call inside startDialogue() around line 333)

Context for the engineer:

  • This file already has const notifySpeakingProgress = inject<SpeakingNotify>('notifySpeakingProgress', () => {}) at line 114 — model the new inject after it.
  • The fresh-start success path is startDialogue() at line 299. After createSession returns, preparedSession.value is set with { sessionId, expiresAt, currentRound } (line 327-331), then dialogueState.value = 'chatting' (line 332), then notifySpeakingProgress('active', { configId, sessionId }) (line 333-336).
  • The resume path is loadLatestStudentSession() at line 375. It also fires notifySpeakingProgress('active', ...) at line 426. DO NOT call recordSpeakingStart from this path — that would write a duplicate work record on every page refresh.
  • The completion path handleDialogueComplete at line 442 fires notifySpeakingProgress('completed', ...). DO NOT call recordSpeakingStart from this path either.

  • [ ] Step 1: Add the inject

Find the existing line at src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue:114:

const notifySpeakingProgress = inject<SpeakingNotify>('notifySpeakingProgress', () => {})

Insert immediately after it:

const recordSpeakingStart = inject<(sessionId: string) => void>('recordSpeakingStart', () => {})
  • Step 2: Add the call inside startDialogue() only

Find the block at src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue:332-336:

    dialogueState.value = 'chatting'
    notifySpeakingProgress('active', {
      configId: props.configId || '',
      sessionId: preparedSession.value?.sessionId || '',
    })

Add a new line immediately after the closing }) of notifySpeakingProgress, still inside the try block, still at the same indentation as notifySpeakingProgress:

    recordSpeakingStart(preparedSession.value?.sessionId || '')

After the edit, that block should read:

    dialogueState.value = 'chatting'
    notifySpeakingProgress('active', {
      configId: props.configId || '',
      sessionId: preparedSession.value?.sessionId || '',
    })
    recordSpeakingStart(preparedSession.value?.sessionId || '')
  • Step 3: Verify resume and completion paths were NOT touched

Run: grep -n "recordSpeakingStart" /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue Expected output: exactly 2 lines — one inject(...) line near line 115, one recordSpeakingStart(...) call inside startDialogue near line 337. Not 3 lines, not any line near 426 (resume) or 452 (completion).

If grep shows more than 2 lines, remove the extras.

  • Step 4: Type-check

Run: cd /Users/buoy/Development/gitrepo/PPT && npm run type-check Expected: PASS (no new TS errors).

  • Step 5: Commit
git -C /Users/buoy/Development/gitrepo/PPT add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
git -C /Users/buoy/Development/gitrepo/PPT commit -m "$(cat <<'EOF'
feat(speaking): record start work entry on fresh "开始对话" click

Injects recordSpeakingStart and fires it once inside startDialogue(),
right after the existing notifySpeakingProgress('active', ...) broadcast.
The resume path (loadLatestStudentSession) and completion path
(handleDialogueComplete) deliberately do NOT call it — idempotency is
enforced purely at the call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: Manual verification

Context for the engineer:

  • This codebase has no unit tests for the affected Vue components. The four scenarios below are the verification gate per the spec.
  • You need student-mode access to a course slide containing a tool-77 (English-Speaking) frame. The student-mode URL has ?mode=student&userid=<id> query params, and the parent Student/index.vue must have been loaded with props.courseid and props.userid set. If you don't have such a course set up, ask the user for a test URL before proceeding.

  • [ ] Step 1: Start the dev server

Run: cd /Users/buoy/Development/gitrepo/PPT && npm run dev Expected: Vite dev server starts, prints a local URL.

  • Step 2: Happy path — fresh start fires the API
  1. Open the student-mode URL for a course slide that contains a tool-77 frame.
  2. Open DevTools → Network. Filter on addCourseWorks_workPage.
  3. Click 开始对话.
  4. Observe a POST .../addCourseWorks_workPage request. Inspect the request body — the single-element array should contain:
    • uid = the student id (matches userid query param)
    • cid = the course id
    • stage = "0"
    • task = the current slide index as a string
    • tool = "0"
    • atool = "77"
    • content = the same sessionId returned by the immediately preceding createSession call
    • type = "21"
  5. Confirm the dialogue UI advances to 'chatting' (the chat view appears).

Expected: all of the above. If any field mismatches, fix the implementation before continuing.

  • Step 3: Resume path — refresh does NOT fire the API
  1. With the dialogue still in 'chatting' (do not finish), refresh the page.
  2. The view should auto-resume into 'chatting' (via loadLatestStudentSession finding an active session).
  3. No POST .../addCourseWorks_workPage request should appear in the Network tab during this resume.

Expected: zero new addCourseWorks_workPage calls. If one fires, the call accidentally landed in the resume path — review Task 2 Step 3.

  • Step 4: Teacher mode — clicking "开始对话" does NOT fire the API
  1. Open the same slide as a teacher (no mode=student query param, or mode=teacher — whatever sets props.type === '1' on Student/index.vue).
  2. If "开始对话" is reachable in this mode, click it. Otherwise, confirm the button is not reachable, which is also a valid pass.
  3. No POST .../addCourseWorks_workPage request should appear.

Expected: zero addCourseWorks_workPage calls.

  • Step 5: Failure path — API 500 does NOT block the dialogue
  1. In DevTools Network tab, right-click addCourseWorks_workPage → Block request URL (or use Override > "Failure response").
  2. Refresh, click 开始对话 again on a slide that does NOT have an existing active session (so the fresh-start path runs).
  3. Observe: the request fails (red in network tab), console.error shows [speaking] recordSpeakingStart failed: ..., and the dialogue still proceeds to 'chatting' normally.

Expected: dialogue UI is uninterrupted; only a console error.

  • Step 6: If any check fails, stop and report

If any of Steps 2-5 fails, do not commit further. Report which step failed, the actual observed behavior, and the diff between expected and actual. The user will decide whether to adjust the spec or fix the implementation.

  • Step 7: Commit nothing (verification only)

This task produces no code changes. If all checks passed, mark the plan complete and report back to the user.


Self-Review Notes

  • Spec coverage: each requirement from docs/superpowers/specs/2026-05-08-speaking-record-start-work-design.md is covered:
    • Provider in Student/index.vue → Task 1
    • Inject + fresh-start-only call in TopicDiscussionPreview → Task 2
    • All four behavior gates (student-only, courseid+userid required, fire-and-forget, no resume/completion call) → encoded in Task 1 Step 1 (the function body) and Task 2 Step 3 (grep guard)
    • Manual verification of all 4 scenarios from the spec → Task 3 Steps 2-5
    • index2.vue open question → resolved during planning (no change needed)
  • Placeholder scan: no TBD/TODO/vague items.
  • Type consistency: recordSpeakingStart signature is (sessionId: string) => void in both the provide (returns Promise<void>, callers use void because they don't await) and the inject. The mismatch (provide returns Promise, inject types as void) is intentional — the call site fire-and-forgets.