diff --git a/.project-docs/30-worklog/tasks/20260922-teacher-presence-a38c72e1.md b/.project-docs/30-worklog/tasks/20260922-teacher-presence-a38c72e1.md new file mode 100644 index 00000000..100c871d --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260922-teacher-presence-a38c72e1.md @@ -0,0 +1,224 @@ +# Task: Add teacher presence and proactive classroom check-ins + +## Identity + +- Task ID: 20260922-teacher-presence-a38c72e1 +- Mode: Feature +- Branch: codex/20260922-teacher-presence-a38c72e1-teacher-presence +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore-teacher-presence +- Base commit: 12d800ec519aface444052b1478843d7caa70f34 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Keep the teacher in the upper-right header, with direct consultation through its published avatar/name, periodic contextual teacher speech bubbles, and continuity with project teacher history. +- Open the current work automatically from the work tab, reusing a running page or asking the coding Agent to prepare it through an internal product action. + +## Work Preview Excess Whitespace Follow-up + +- Same feature task resumed with matching registry/worktree/branch/base; current and read-only classroom peer records rechecked. Prior project context remains applicable; established light classroom layout takes precedence over generic design recommendations. Peer scope is already the integrated base and has no new semantic conflict. Gate passed. No subagents. User now requires consent before operating their live application; do not launch, raise, reload, or focus it during this repair. +- Evidence: the supplied screenshot shows a large empty band above Work. CSS reserves 144 px (116 px on short windows) whenever any teacher bubble is visible, regardless of horizontal overlap. With consultation open, the bubble sits over the right pane but still pushes the separate work pane down. Even without consultation, the fixed reservation ignores the existing preview toolbar height. +- Plan: remove fixed full-pane padding and measure the bubble against the actual native viewport. Add only the required clearance when their horizontal ranges overlap, recompute for message/window/pane size changes, and remove clearance on dismissal or non-overlap. Preserve bubble visibility, native preview bounds, drafts, and existing occlusion handling. Validate focused unit tests plus isolated headless layout rendering without touching the user's live window; update the Electron regression for the same geometry. +- Completed: removed the 144/116 px work padding. The actual bubble element is passed to the embedded preview, where a layout effect measures native-page overlap and adds only missing clearance after accounting for existing DOM toolbars. ResizeObserver follows bubble/viewport dimensions and window resizing; repeated observations subtract the previously applied offset to avoid oscillation. Separate side panes and short messages need no extra gap, and dismissal/unmount immediately restore space. README and the existing Electron geometry assertion are updated. +- Verification: 4 focused unit suites / 58 tests passed, including non-overlap, exact minimal clearance, observer stability, pane changes, toolbar clearance and cleanup. Typecheck, scoped ESLint, production Renderer/Main build, and whitespace checks passed. Added an isolated headless browser layout fixture using the real TeacherCompanion, AgentBrowserPanel and classroom styles with mocked local transport; three runs passed at 1440×900, 1180×720 and 1000×640, covering side-pane width changes, long/short text, dismissal, and native-viewport non-overlap. Inspected the large and compact screenshots: Work begins 20 px below the tabs beside the teacher pane, with no reserved empty band. +- Validation environment: bundled headless Chromium was absent and its download stalled; stopped only that task-owned download and ran the fixture in an isolated headless installed Chrome session. An initial fixture mock returned an asynchronous unsubscriber; corrected it to the actual synchronous transport contract and reran all three sizes successfully. No user app/window/session was controlled, relaunched, focused or refreshed. Native Electron spec assertions were updated but not executed in this follow-up to avoid opening a foreground test window. The existing live trial will load the built Renderer on the user's next refresh; native live-window verification is not claimed. +- Promotion candidate: replace fixed full-pane teacher clearance with measured native-page overlap in classroom interaction guidance. Evidence: user screenshot, fixed CSS diagnosis, focused unit and headless rendering tests. This restores the approved layout without changing teacher behavior or user work. Canonical changes remain for integration. + +## Missing Bubble Visibility Follow-up + +- Same task resumed with matching ownership, prior project/design context and peer registry checked; gate passed. User still cannot see teacher bubbles. No subagents. +- Evidence: only two completed proactive turns exist in the real teacher topic, most recently 2026-09-23 00:15 UTC, while ordinary teacher exchanges continue through 02:50 UTC. Main permanently skips a previously seen operation-text fingerprint without considering newer teacher discussion. Renderer resets its five-minute deadline on every mount and has no fallback read if the topic stream disconnects. Only completed unread check-ins render a bubble; the published welcome text is unused there. +- Plan: show the published welcome once per account/project on entry without a model call, let genuine unread replies take precedence, persist the periodic deadline across reloads, reconcile running teacher replies via bounded read-only polling, include completed student/teacher discussion in source-change detection, and allow unchanged-context check-ins at a slower fifteen-minute interval. Preserve dismissal, existing five-minute minimum, project/account boundaries, real Agent output, drafts and preview clearance. Verify with focused tests, Electron interaction and a native UI attempt. +- Completed: the operations-configured welcome appears once per account/project with no model call; unread real check-ins take priority, and explicit dismissal acknowledges the visible message without exposing a second greeting. The periodic attempt timestamp survives reloads. Main includes completed ordinary teacher discussion in its context fingerprint, ignores check-ins themselves, and limits unchanged-context checks to fifteen minutes instead of permanently suppressing them. Pending topic results reconcile every fifteen seconds while visible through read-only requests, without resubmitting a model action or changing the selected teacher topic. Busy work defers to the next polling tick rather than restarting a full five-minute wait. Native preview clearance covers both welcome and actual guidance. +- Verification: teacher service, companion, and chat panel suites passed (112 tests before the final read-only selection regression; the affected service/companion suites then passed all 82 tests, for 113 relevant cases overall). Typecheck, scoped ESLint, production Renderer/Main build, and whitespace checks passed. All three selected Electron interactions passed, including immediate published greeting without a model call, later real guidance, preserved student draft/focus, teacher entry selecting Work, and actionable work recovery. The first proactive E2E run exposed an ambiguous duplicate teacher-name test locator; the selector was narrowed to the identity button and the spec passed on rerun. +- Native evidence: temporary Electron PID 72163 runs this task's checkout with the existing isolated profile. After earlier stale/no-op UI observations, the user's correction prompted a fresh app binding. Its actual route is `/chat`, and both accessibility and screenshot now show the real pale-green bubble below the upper-right published teacher avatar. It starts with “咱刚聊到「你自己做出来那一下是什么感觉」” and offers “和老师聊聊” / “等会儿聊”. The user does not need to return to the home screen. The installed Makelore process is a different app on project configuration and was only inspected; it was not updated. No student message or game action was sent during this verification. +- Runtime limitation: the final non-selecting poll safeguard was built after PID 72163 launched; its service/UI behavior is covered by the 82-test final run and will load on the next trial launch. The visible native greeting/check-in fixes were already loaded and are verified in the current conversation window. Avoided another disruptive restart after obtaining the requested visible result. +- Promotion candidate: document immediate operations-owned welcome, reload-stable periodic timing, ordinary discussion as progress context, fifteen-minute unchanged-context cadence, and read-only pending-result reconciliation. Evidence: observed missing bubbles, targeted regression coverage, Electron interaction tests, and actual trial bubble screenshot. No additional product approval is needed; canonical promotion remains an integration responsibility. + +## Actionable Failure Recovery Follow-up + +- Same-task ownership and project context checked; gate passed. User approved replacing generic failures with useful next actions. No subagents. +- Evidence: the trial's latest failed internal work-open turn contains an empty assistant error and a Works Square expired-login 401. Projection loses the login reason and the hidden internal prompt removes its context. +- Plan: preserve structured login failures; show one contextual recovery card for the latest failed action; route login back to the same account/project/action; preserve operation drafts during continuation; reuse the Main work action for checking/opening; bound transport retries with the same request identity. Keep uncertain dispatches from being automatically replayed. Validate service, hook, timeline, login and Electron interactions, then build. +- Completed: expired Works Square login messages now project safe structured auth notices. Latest failed turns offer login/continue/check-work/refresh/model/account actions outside collapsed process details; empty assistant errors no longer repeat a generic failure. A healthy work preview removes its old work-failure entry. Internal continuation instructions remain hidden and do not name conversations. +- Recovery preserves the current operation text and stored draft attachments while submitting a separate continuation using durable history. It refreshes state first, avoids active/uncertain requests, checks account/project scope after runtime recovery, and suppresses double clicks. Login return carries a one-use account/project/conversation target and requires fresh login even when Renderer auth looks valid; changed accounts or projects do not resume the old action. Existing teacher/friend drafts retain their own persisted storage. +- Work startup reports structured auth errors; renderer transport checks retry at most twice with the same action identity, then expose explicit reconnect. Known terminal work failures require an explicit new attempt. No raw provider diagnostics are exposed as recovery copy. +- Verification: 9 focused unit suites passed (155 tests), then 2 added matching-account login-return cases passed with the complete chat panel suite (31 tests). Final changed-component regressions passed (41 tests). Typecheck, scoped ESLint, production Renderer/Main build, git diff whitespace check, and three Electron regressions passed. Electron coverage includes failed work → check/open → ready, removal of recovery UI, retained draft, and one fresh explicit action identity. Reviewed the captured recovery screen visually. +- Native trial: both inputs were empty and no file was selected before updating. Relaunched only the isolated trial with its existing profile (PID 71039); installed production app and game source were untouched. Native AX verified the new build route, alice account, 219 px sidebar, and known 5179 work address. Native click control again reported user/window changes and AX clicks did not reliably switch tabs; the work canvas capture was blank, so no claim of live-page rendering or native recovery-button verification. Login/continuation actions were validated with fixtures, not by submitting user credentials or deliberately triggering a live provider failure. +- Promotion candidate: document contextual failure recovery, bounded idempotent transport checks, and account-bound login return in the Code workspace interaction contract during integration. No canonical docs modified here. + + +## Hide Student Preview Diagnostics + +- Same-task feature ownership resumed; current/peer scope and prior planning context rechecked, gate passed. User asks to hide the Console/Network strip below their work. Limit this change to the embedded student work preview, preserving the Agent's backend debugging tools and the standalone developer browser. +- Completed: embedded work previews omit the entire Console/Network diagnostics strip and its Renderer diagnostics subscriptions. Standalone developer preview and backend Agent debugging remain available. Updated README and existing unit/Electron assertions. +- Verification: browser panel unit suite 9/9 passed, project typecheck and scoped ESLint passed, Vite/Electron build passed, Electron work-tab regression 1/1 passed, and git diff whitespace check passed. +- Native verification: both student and teacher inputs were empty before refreshing. Reloaded only the Renderer using View > Force Reload; Main process and signed-in alice account stayed alive. After bringing the temporary window to the front, the actual game at http://127.0.0.1:5179/ rendered inside Work and the Console/Network strip was absent in both accessibility tree and screenshot. Left the trial on the functioning work preview. No game action, model message, or project source edit was performed. +- Promotion candidate: Code workspace UI documentation should record that embedded student work hides developer diagnostics while Agent tools retain them. Evidence: explicit user request, regression assertions and native preview verification. No semantic conflict or further approval required; canonical updates belong to integration. + +## Intent And Constraints + +- User approved development after product discussion; at most one subagent. Teacher identity is cloud operations-owned. Keep existing light classroom UI, source-text context, student-owned implementation, friend, and existing drafts. No added visual teaching system or automatic coding tasks. Prior student-only invocation rule is superseded by explicit authorization for periodic proactive teacher messages. +- User subsequently requested removal of the separate hand-raise button. Avatar/name remains the consultation entry, and closing the consultation returns keyboard focus there. + +## Outcome + +- Implemented the teacher identity using the existing published name/avatar in the upper-right header; it stays visible in operation chat and work tabs. The separate hand-raise button and its copy/styles were removed following user feedback. +- Periodic foreground checks invoke the real teacher pipeline with completed source conversation context after five minutes. Main enforces account/project ownership, enabled configuration, cooldown, source-change fingerprinting, request idempotency, and running-request guards. System-originated check-ins do not fabricate student messages. +- Teacher output appears in a compact bubble below the upper-right avatar with chat/later actions, or in the already-open consultation. Opening/dismissing retains the same project history. Main composer and consultation drafts, focus, work view, and friend role are preserved; unfinished teacher drafts defer automatic checks. The work tab temporarily reserves space above the native preview while the bubble is visible, preventing Electron's native view from covering it. +- Added an isolated trial launcher and opened the new checkout with the existing trial profile. No production installation, merge, or server changes. +- Removed the normal-message technical acceptance notice. Fixed accepted-request reconciliation with the matching run's terminal state, including completion before the HTTP acknowledgment. Renderer optimistic nodes no longer count as Main receipt evidence. Session projection preserves confirmed same-generation terminal identity and matches persistent message IDs before text, preventing repeated messages from borrowing another request identity. + +## Verification + +- TypeScript typecheck passed; scoped ESLint and git diff whitespace checks passed. +- Focused Vitest: 4 files / 118 tests passed, including teacher API/service/context and UI timer/history/draft protections; repeated successfully after removing the hand-raise button. +- Production Vite/Electron build passed; existing bundle-size/Browserslist warnings only. +- Electron Playwright: 2 interaction tests passed, covering persistent presence across tabs, proactive bubble without focus theft, same-history chat, dismissal, retained student drafts, and existing teacher/friend consultation. Both passed again after the button removal, including focus restoration to the teacher identity. Native preview was rebuilt/relaunched and confirmed the button is absent, the published identity remains visible on both tabs, and clicking it opens consultation. +- Native trial confirmed published teacher identity and consultation opening. Current trial conversation has no completed source content, so automatic guidance correctly has no context to send. Successful live model completion was not verified; existing historical trial requests already showed generic model failures. Proactive preview screenshots are explicitly fixture-backed examples. +- Upper-right follow-up: 72 relevant UI/titlebar/browser unit tests and both Electron interaction tests passed; typecheck, scoped ESLint, production build, and whitespace checks passed. E2E verifies the bubble is below the avatar, right-aligned, completely visible, and above the native work-preview rectangle. Trial relaunched from the updated checkout. Native UI automation could observe the module selector but failed to click it with `noWindowsAvailable`; the actual Electron interaction and screenshot were verified by E2E. Copied the fixture-backed screenshot to `/Users/chillishark/Makelore 麦洛/课堂工作区预览/teacher-bubble-top-right.png` and opened it in Codex for immediate review. +- Stalled-send follow-up: notice-removal UI tests 44/44 passed; store/session-projector/runtime/chat regression tests 86/86 passed, including 11 new cases (10 reproduced failures before the fix). Typecheck, scoped ESLint, production build and whitespace checks passed. Electron E2E 4/4 passed, including matched-run release without client-tagged history, rejection of an older run's completion, preserved next draft, no duplicate submit, first-message startup, consultations and proactive bubble. +- Native trial restarted from the corrected build. Verified the orphan third hello and acceptance notice are absent, Send is enabled, operation/work tabs respond, and the exact unsent draft "我的游戏在哪里哦" is restored. No student message was sent, no project/history files were edited, and the installed application was untouched. + +## Follow-ups + +- User review of the teacher location and proactive bubble. Verify a successful live teacher model response with usable project context/configuration before release. + +## Promotion Candidates + +- Target: current-state, teacher architecture/ADR, and teacher domain rules. Proposal: extend explicit-only teacher consultation with user-authorized periodic contextual check-ins and persistent avatar/name presence. Evidence: explicit user request, implemented Main safeguards, 118 focused tests and Electron interaction evidence. Future impact: proactive invocations use existing model billing/configuration and must remain bounded/idempotent. Semantic conflict: earlier classroom/teacher docs say student-only invocation; this session explicitly authorizes the extension. No further human confirmation is needed for this feature direction, but canonical promotion remains an integration-task responsibility. +- Target: conversation-state architecture and reliability evidence. Proposal: track accepted run identity through same-generation terminal hydration and resolve repeated messages by persistent identity before signature. Evidence: reproduced stale accepted blocking state and focused failing-then-passing regressions. Future impact: prevents permanent composer lock and wrong request association while retaining anti-duplicate guards. No semantic conflict or extra human confirmation required; canonical promotion belongs to integration. + +## Project Context Loaded + +- Task: 20260922-teacher-presence-a38c72e1, feature, branch codex/20260922-teacher-presence-a38c72e1-teacher-presence, owned worktree /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore-teacher-presence, base 12d800ec519aface444052b1478843d7caa70f34. Ownership and task reservation verified. +- Read: AGENTS, README, memory-index, read-before-planning, planning-gate, task record, project-positioning, current-state, decision-index, system-overview, teacher ADR, relevant business-rules/success-criteria, commitments, and peer classroom task record. +- Project goal: AI creation workspace with Main-owned Code execution and independent text-only teacher/friend consultation. +- Peer classroom task is ready for integration and is the committed base. Shared code scope overlaps but this task has its own worktree; no peer edits. Its explicit-only trigger rule is intentionally extended by this user's new approved design. +- Stale docs: project-positioning is a template; canonical teacher architecture predates project-level role consultations. Current README, committed code, and current user instructions govern this iteration. Real teacher model requests previously failed generically; successful external model response remains an integration uncertainty. +- Affected modules: teacher service/context/routes/contracts, teacher UI and Code workspace, scoped regression tests and README. No server repository edits. +- Gate: Passed. + +## Implementation Plan + +1. Add bounded, deduplicated proactive check-ins via the existing teacher pipeline, preserving cloud configuration and marking system-originated messages. +2. Add a persistent teacher area with cloud identity, avatar/name consultation entry, short proactive bubble and chat/later actions; maintain same teacher topic and preserve drafts. +3. Test timers, no duplicate/overlapping calls, project/account isolation, dismissal and chat handoff, then typecheck, build, Electron interaction verification. +4. Launch the isolated iteration for user review and record limitations and outcome. + +## Right-corner Bubble Follow-up + +- Same task resumed with matching feature ownership, branch, worktree and recorded base; classroom peer remains ready for integration. Previously loaded project context still applies; peer scope and current task record rechecked. Gate passed. User explicitly moves teacher identity back to the upper-right header and keeps proactive speech bubbles. No separate hand-raise action. +- Move the existing published-identity component into the header; anchor the short message below it. Preserve Agent pipeline, receipts, conversation continuity and composer focus. Show the actual rendered bubble using clearly identified test content so user can review it without waiting five minutes or requiring a working live model. +- Completed: header avatar/name restored, bottom row removed, visible message indicator and anchored bubble added, work-preview space reserved only while speaking, all remaining hand-raise wording removed from the new companion flow. Preview is shown with explicit test-example attribution; no fake model reply was added to the user's conversation history. + +## Acceptance Notice and Stalled Send Follow-up + +- Same task resumed in its owned worktree; ownership, project-docs validation, current record and unchanged peer scope rechecked. Prior project context still applies. Gate passed. User explicitly asks to remove the technical acceptance notice and repair the apparently frozen trial. +- Native evidence: window responds to closing the teacher panel; the operation draft is editable but Send is disabled. Timeline shows two durable hello messages (first failed, second answered), plus an orphan optimistic hello marked submitting. Existing trial session file contains only the two durable user messages. Native teacher history now includes a successful help response and a proactive check-in generated by the real pipeline. +- Plan: remove only acceptance-status presentation; preserve receipt/idempotency gates. Reproduce missing terminal reconciliation and repeated-message identity issues with focused tests, fix the proven state transitions, rebuild and relaunch the isolated trial, restoring the student's unsubmitted draft without sending it. +- Completed: implementation and regression evidence above; native application is responsive with the original unsent draft and enabled Send. Local session contents were read for diagnosis only. Successful teacher pipeline output was observed in existing user-generated history, superseding the earlier absence of live-response evidence; no new real model request was made during repair. + +## Automatic Work Preview Follow-up + +- Same-task ownership and planning context rechecked in the existing feature worktree; peer classroom task remains read-only. Gate passed. User approved an internal instruction that reuses a running work page or starts it when needed. User now forbids subagents; this follow-up is completed locally. +- Plan: add a Main-owned, project-scoped preview request with a fixed startup instruction, known-page health check, busy waiting and duplicate protection; invoke it from the work tab, show simple progress/retry states, preserve drafts, and render the internal action as a short system entry. Validate with service/UI regressions, Electron interaction, typecheck and build; relaunch the isolated trial. +- Completed: Renderer-capability-only `ensure-work` route resolves the active project in Main. A project-scoped service checks the known loopback page, serializes requests, waits for current project work, and sends one fixed internal action to the coding Agent if needed. The prompt instructs inspection/reuse before minimal startup and a real `agent_browser.open`. It does not consume student drafts/attachments or name the conversation. The timeline displays a short system-action entry. Work UI polls the same identity, shows preparation/waiting/failure, permits explicit retry, and ignores responses from prior projects. Existing manual browser controls and teacher occlusion remain supported. +- Verification: 65 focused tests passed across preview service/hook, browser routes/panel, chat and title tests; an expanded service/hook/browser/timeline run passed 51 tests, including new system-entry rendering, failure/retry, and Renderer/project boundary checks. The final hook adjustment passed its 3 regressions. Scoped ESLint, normal `corepack pnpm run typecheck`, production build, and whitespace checks passed. Electron E2E passed 5 tests: automatic preview with unchanged draft and stable request identity, first-message behavior, settled-send recovery, teacher/friend consultation and teacher bubble. An additional Main-wide TypeScript check reports existing errors in untouched modules (composition, billing projections, fetch typings, etc.); no diagnostics reference the new preview service, its route, or title change. This broader check is not reported as passing. +- Native verification: relaunched only the isolated trial, PID 60583. Before restart the current draft was empty. Clicked Work in `alice的游戏`: the real Agent inspected the project, found the existing Vite process serving port 5179, checked the page, and opened `http://127.0.0.1:5179/` with `agent_browser`. Native screenshot confirms the actual “我的同桌是宠物” game appears inside Work. Switching to chat shows only “已点击打开作品” for the internal instruction; switching back reuses the same page. Left the trial on Work. No server restart or new feature was requested from the project Agent; the no-running-service branch is covered by service/UI tests rather than stopping the user's existing service. +- Promotion candidate: update Code workspace architecture/domain rules to include user-clicked work startup through the existing coding Agent, with reuse-first behavior, project scoping, busy waiting and request deduplication. Evidence: explicit user approval, regression tests, and successful native model/browser trace. Future impact: opening an unavailable work preview can invoke the configured model; it must remain separate from teacher/friend consultation and must preserve unsent input. No semantic conflict beyond extending the previously manual URL workflow; no additional human confirmation needed. + +## Work Preview Resume Repair + +- Same task resumed with matching feature ownership; current and peer records rechecked, earlier loaded project context still applies, gate passed. User reports the work is missing. Native state confirms an error placeholder while the actual game service still responds HTTP 200 at port 5179. Main logs and composition show background sleep closes the native browser; the trial uses E2E's accelerated 250 ms idle timer. The previous native verification covered first open and tab switching but missed this lifecycle transition. +- Plan: preserve Main-known healthy work URLs across browser disposal, restore on foreground without asking the Agent again, and protect the opening operation from idle disposal. Add deterministic sleep/resume regressions and native verification after switching away and returning. Keep existing background cleanup and user drafts intact. No subagents. +- Completed: Main retains up to 100 verified addresses bound to project ID and resolved path for this application session, probes them before restoring a disposed view, and holds an activity lease while preparing the preview. The work hook pauses checks when hidden and rechecks on visibility/focus using the same action identity, with overlapping checks deduplicated. Browser state application ignores older generations so a late closed-state response cannot replace a restored view. Existing runtime/background cleanup remains enabled. README updated. The user later permitted one subagent if necessary; none was needed or used. +- Regression evidence: the new disposed-view and foreground-focus tests failed before the fix. Final focused run passed 41 tests across service/hook/browser/routes, including inactive-server fallback, hidden-state pause, duplicate focus events, stale snapshot ordering and lease acquisition/release. Typecheck, scoped ESLint, build and whitespace checks passed. Electron E2E passed all 4 selected tests, including work-tab restoration from a closed-state event on foreground, conversation resume, consultation and teacher bubble. +- Native evidence: restarted only the isolated trial (PID 61454) after confirming both inputs were empty. This restart's new model request failed with the existing generic provider error; the trial is signed out and teacher now reports login required, so successful fresh model startup is not claimed for this follow-up. Restored the already-running game using its previously verified address through the normal address field, then re-entered Work so Main verified and retained the page. Hid the app using Cmd-H: native game content disappeared after the accelerated background cleanup. Raised the same window: the actual game reappeared automatically at port 5179 without another startup action. Opened teacher consultation alongside it and verified both panes remain visible. Left that window raised with Work selected. User's game process, source files, and gameplay data were not edited. +- Limitation: the verified-address cache lasts for the Main process, not across application restarts. A fresh process with no known work address still uses the configured Agent and can show a real provider/login failure; the repair does not fabricate successful model execution. Durable preview-address persistence is outside this targeted sleep/resume fix. + +## Teacher Bubble Visibility and Workspace Proportions + +- Same feature task resumed with matching branch/worktree/base ownership; active and peer task records and planning context rechecked. Peer classroom task remains read-only and ready for integration. Gate passed. Retain the established light sage classroom design; generic design-skill suggestions do not replace it. +- User asks why proactive speech is not visible, requests the left column at half width, a wider teacher pane, and a prompt-style “老师帮我看看” action in place of the footer hint. Latest instruction requires explicit permission before any subagent; none used. +- Diagnosis: five-minute checks plus automatic dismissal while teacher consultation is open hide the bubble; matching unchanged source context also suppresses model calls by design. +- Plan: keep periodic/cost guards, preserve new proactive invitations even with the pane open until explicitly viewed/dismissed, and avoid losing an unseen check-in when another reply follows. Narrow Code sidebar from 256 to 128 pixels with compact controls, widen consultation default from 380 to 508, and relocate the help shortcut into the composer footer. Update focused UI/Electron coverage, build, reload only the temporary Renderer, and verify. + +- Completed: Code rail and matching titlebar surface are 128px, with compact project toolbar, full-name tooltips, and preserved native window-control hit areas. Consultation defaults to 508px and can expand/resize to 640px; compact-window overlay rules remain. “老师帮我看看” is the single teacher shortcut, styled as a pill in the composer footer; removed the old footer hint and duplicate empty/history help buttons. Existing model intent and draft preservation remain. +- Bubble fix: finds the latest unread completed check-in, including when later requests follow, and no longer treats an open teacher panel as an acknowledgment. Opening the avatar preserves the invitation; the invitation's view/later actions explicitly acknowledge it. No automatic model cadence or Main cooldown/fingerprint rules changed. +- Verification: 51 focused unit tests passed (teacher UI/companion, titlebar, feature UI); final companion adjustment passed all 7 companion tests. Typecheck and scoped ESLint passed. Production build passed. Both Electron interaction specs passed: proportions/help/draft behavior, and periodic bubble with consultation already open. First E2E attempt read a transient missing sidebar bounding box; switched to waiting on computed width. A later clock-controlled attempt advanced before cleared-draft effects settled; synchronized controlled frames and the polling tick, then the bubble spec passed. No passing full-suite claim. +- Native verification: refreshed only the trial Renderer with alice still signed in and operation draft empty. Screenshot confirmed half-width rail, healthy actual game at port 5179, and a real newly received proactive bubble discussing the user's pet game. The model call came from the existing periodic mechanism, not fabricated test content. Final minor avatar-acknowledgment adjustment was built and tested; native menu/click automation subsequently returned stale-element/noWindowsAvailable, so its final in-process reload and opening the wider pane could not be confirmed. No Main restart, game actions, student messages, or user-source changes were made. Wider pane and footer layout were visually inspected in the Electron test screenshot. +- Promotion candidate: Code UI documentation should record half-width navigation, wider consultation, footer prompt shortcut, and explicit-only read acknowledgment for proactive invitations. Evidence: user request, implementation, UI/Electron coverage, native layout and real bubble. No semantic conflict with already-authorized periodic teacher checks; canonical changes remain for integration. + +## Adjustable Left Sidebar + +- Same-task ownership resumed; active and peer records rechecked, previously loaded project context and approved classroom design still apply. Gate passed. User requests one-third more than the current 128px width, with dragging up to twice that current width. Default 171px, range 128–256px. No subagent permission requested or used. +- Plan: add a local persisted width shared by sidebar/titlebar/header; implement the right-edge pointer and keyboard resizer with bounds and reset, preserve sidebar collapse/peek and native chrome, then validate drag/persistence/header alignment in focused tests and Electron, build, and reload the temporary Renderer if native controls respond. +- Completed: default width is 171px; a six-pixel right-edge separator supports pointer dragging within 128–256px, arrow/Home/End keyboard adjustment, and double-click reset. The local settings store remembers the width without backend writes. Sidebar, titlebar background, and conversation header share the same normalized width; collapsed peek behavior and native window-control safe areas remain intact. README reflects the new sizing. +- Verification: 35 focused unit tests across five files passed; normal TypeScript check, scoped ESLint, production build and whitespace checks passed. The Electron consultation interaction test passed with new drag-to-maximum, keyboard bounds, collapse/expand, reload persistence, titlebar/header alignment and reset assertions, followed by existing teacher/friend draft-preservation coverage. Its screenshot was visually inspected. +- Native verification: refreshed only the trial Renderer, preserving Main PID 61454 and signed-in alice. Accessibility confirms the new separator at 171px and a native screenshot confirms the wider sidebar. After refresh the conversation briefly showed the existing local-runtime unavailable state; the normal retry followed by bringing the window forward restored the original history and removed that error. No new student/model message was sent. Native coordinate drag verification was blocked by the automation surface reporting noWindowsAvailable; actual pointer dragging was verified in the Electron interaction test. Left the trial at the default width with operation conversation visible; no Main restart or user project edits. + +## Window Controls Investigation + +- Same task resumed in its owned feature worktree with matching branch/base/registry; active and peer records and project context rechecked. Classroom peer remains read-only; no conflicting scope. Gate passed for diagnosis. No subagent used. +- User reports missing minimize/maximize/close controls. Current native Mac trial screenshot and accessibility tree show all three system traffic-light controls at the upper left. Source keeps native macOS chrome, custom three-button Windows controls, and native Linux chrome. No absence was reproduced in the current window; no speculative UI changes made. +- Asked whether the user wants explicit dash/square/close controls, sees disappearance after navigation/fullscreen, or refers to Windows. Awaiting that detail before changing the platform design or a specific failing state. + +## Simplify Consultation Reply Actions + +- Same-task feature ownership, active/peer scope and previously loaded project context rechecked; gate passed. User approves hiding “我去试一试”, consultation “复制”, and the internal “已点击打开作品” timeline entry. No subagent used. The separate window-controls clarification remains pending. +- Plan: remove the unused consultation return action and visible copy buttons; hide internal work-open prompt nodes while retaining Main startup/deduplication, durable history and real results/errors. Update existing focused unit/Electron assertions and README, then typecheck, build and reload only the trial Renderer after checking drafts. +- Completed: removed consultation reply copy/return buttons and the unused return callback. Internal work-open user nodes render no UI, including existing history. The actual work startup pipeline, stored nodes, operation-chat reply copy, results/errors and admin-only legacy bring-back behavior remain intact. README synchronized. +- Verification: 47 focused tests passed across teacher UI, timeline and feature UI; existing assertions now cover teacher/friend action absence and hidden startup instructions with preserved result/history. Typecheck, scoped ESLint, production build and whitespace checks passed. Both Electron interaction tests passed for consultation/draft preservation and automatic work preparation/resume; the consultation screenshot was visually inspected. +- Native attempt: current trial account alice and both empty inputs were verified before Renderer refresh; preserved the user's new 219px sidebar width and Main process. Refreshed via the native View menu. Renderer reloaded but initially showed local-runtime unavailable, so invoked its normal retry as in the prior follow-up. Subsequent UI observation reported the Mac is locked and automatic unlock unavailable; final history/teacher-panel visibility could not be confirmed. Need manual unlock to complete native verification. No model message, project-source edit, Main restart, or native close/minimize action was performed. +- Promotion candidate: update classroom UI guidance to keep consultation replies free of redundant return/copy actions and hide the internal work-open instruction in the student timeline. Evidence: explicit user request and focused unit/Electron checks. Only presentation changes; startup execution and history retention are unchanged. No semantic conflict or additional design approval needed; canonical promotion remains an integration responsibility. + +## Open Work Alongside Teacher Consultation + +- Same-task ownership and peer scope verified; previously loaded project context and approved classroom layout still apply, gate passed. User wants teacher conversations to default to the Work tab. No subagent used. +- Plan: have the shared explicit teacher entry select Work, covering avatar and proactive bubble actions; preserve operation/consultation drafts and allow manual tab changes afterwards. Unattended proactive bubbles must not switch tabs. Reuse existing work preparation and native preview sizing, update interaction assertions and README, then validate and refresh the isolated trial if unlocked. +- Completed: the shared `openTeacher` handler now selects Work alongside teacher consultation. Both avatar and invitation actions use it, including reopening from a friend conversation. Manual tab switches remain available and retained drafts are unchanged; receiving an unattended check-in does not switch tabs. README updated. +- Verification: 35 focused chat/companion tests passed. Typecheck, scoped ESLint, production build and whitespace checks passed. All three Electron interaction specs passed: avatar-open Work and preserved drafts, proactive invitation-open Work without unsolicited tab switching, and work preparation/background restoration. Friend opening retains the current selected tab. +- Native limitation: Mac is unlocked again; before refresh verified alice, 219px sidebar and both empty drafts, with the teacher pane open on Operation. Native View/Force Reload and Cancel repeatedly reported stale-element errors, including after rebinding the app; keyboard escape/reload did not change the menu state. Final in-process refresh could not be confirmed. Kept the Main session/account alive; no messages sent or user project edits. The build is ready for a manual Renderer reload, while native confirmation of this change remains pending. +- Promotion candidate: document that explicitly entering teacher consultation selects Work by default, while incoming proactive speech preserves the student's current tab. Evidence: user instruction, shared handler, unit and Electron coverage. Reuses existing preview startup semantics and preserves user control; no conflicting decision or extra approval needed. + +## Persistent Failure Text Diagnosis + +- Same-task ownership and current/peer records rechecked; earlier planning context remains applicable, gate passed for read-only diagnosis. User asks why “本次处理失败。” keeps appearing. No subagent, model call, or source-code change. +- Native tree shows the latest operation process group expanded, with the generic failure fallback. Read-only inspection of the bound Pi session found exactly two failed assistant messages, both empty with `401 Works Square login session is missing or expired`: the first hello at 2026-09-22 18:49:42 Asia/Shanghai, and an internal work-open action at 21:49:32. The last persisted operation message is that second failure; there are no newer operation failures in this session. +- Cause: the timeline preserves history; an empty failed assistant message renders the fallback “本次处理失败。” inside its process group. The previous presentation change hid only the internal work-open user prompt, deliberately retaining results/process/error nodes. This leaves the old internal failure visible without its original action label. Background lifecycle stops in the current log are classified intentional background sleep, not new failed model turns. +- Reported the historical 401 and the presentation omission to the user. No assertion that current coding credentials are valid was made; no fresh provider request was sent to test them. Current account label is alice. Any decision to hide or restructure the rest of internal startup turns should preserve current actionable startup errors in the Work area and durable history. + + +## Pixel Teacher Speech Bubble Integration (2026-09-23) + +- Same task resumed successfully in feature mode, branch `codex/20260922-teacher-presence-a38c72e1-teacher-presence`, worktree `/Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore-teacher-presence`, base `12d800ec519aface444052b1478843d7caa70f34`. Registry matches. The classroom peer is the inherited integration base, remains read-only, and has no conflicting newer scope. Existing uncommitted work belongs to this resumed task and is preserved. +- Project context reloaded: memory entry, active/peer task records, positioning, integrated current-state, decisions, system overview, teacher ADR, relevant business/success rules, commitments, README and AGENTS. Template/stale positioning and source-scoped ADR details yield to the already-approved project consultation implementation. Gate passed. +- User approved integrating the conversation prototype into the existing project and opening the temporary app; this explicitly authorizes this turn's launch/refresh, superseding the earlier live-app restriction for this task. No subagents. User clarified: remove the prototype's “老师偶尔来看看” switch, preserve existing proactive teacher messages and periodic backend behavior. +- Plan: bundle the approved pixel portrait, use it consistently at the header and teacher panel; compact the proactive speech bubble and attach a small matching portrait only while there is a real message, preserving source/history/dismissal and native-page clearance. Do not ship prototype controls, fabricated project messages or the opt-in switch. Validate focused visibility/identity/dismissal/clearance cases, typecheck and build; open the same isolated temporary app and verify the visible result. + +- Implemented: copied the approved transparent pixel portrait into `src/assets/consultations/teacher-pixel.png`. Header/fallback header and student teacher consultation use the same bundled portrait, while operations draft preview retains its configured avatar. Actual welcome/unread teacher messages create a compact 232px text bubble beside a 48px portrait, both inside one presence group. No idle floating portrait, no checkbox, and no fake model response or new scheduling path. The existing header remains available after dismissal; both close and later actions remove the whole presence group. Native WebContents clearance now receives the entire presence group. +- Validation: 68 focused cases across companion, teacher panel, preview clearance and chat panel passed; typecheck, scoped ESLint, production Renderer/Main/Preload/utility build and whitespace checks passed. The updated Electron proactive-message regression passed, covering portrait consistency, absence of the switch, message-only presence, grouped dismissal and whole-presence native clearance. All three responsive layout cases passed at 1440×900, 1180×720 and 1000×640 using installed headless Chrome. The initial layout attempt could not launch because bundled Chromium is absent; reran with the fixture's existing `MAKELORE_LAYOUT_BROWSER_CHANNEL=chrome` option. Visually inspected the Electron screenshot: the compact speech bubble is attached immediately left of the pixel portrait, with no extra band above the adjacent work pane. +- Native verification: rebuilt and relaunched only the existing isolated trial/profile through `scripts/launch-teacher-trial.py` (PID 79535), then raised it and opened the current project with teacher consultation beside Work. Native screenshot confirms matching pixel portraits at the project header and teacher pane; no opt-in switch. This unpackaged build stores credentials only in memory (confirmed in its startup log), so restarting Main returned the trial to signed-out state. Real proactive history cannot load until the user signs in again; do not claim a live authenticated bubble was verified or fabricate one. Existing project/conversation history and 219px sidebar remain. The original running pet page was reached through its known address using the normal UI, confirmed by its native accessibility tree; after accelerated background cleanup, the final screenshot showed preparation/blank preview, so sustained preview rendering is not claimed. No game source, gameplay action or student message was edited/sent. The attempted automatic Work entry reported the existing login requirement. +- Promotion candidate: document one bundled pixel teacher identity for student-facing header/consultation, message-only floating portrait and attached compact speech, whole-group dismissal/native clearance, and removal of the prototype opt-in switch while preserving real scheduled check-ins. Evidence: explicit user clarification, actual source integration, focused tests, Electron screenshots and native avatar verification. No conflicting teacher scheduling decision or additional product approval required; canonical promotion remains an integration responsibility. Only authenticated live-bubble verification remains dependent on the user signing in to the temporary app. + +## Student Work Preview Closed-State Recovery + +- Same task resumed in its owned feature worktree/branch/base; registry matches, classroom peer remains read-only without conflicting scope. Prior loaded product context remains applicable; active/peer records, entry/gate, evidence/reflection/commitments and AGENTS rechecked. Gate passed. No subagent used. +- Native evidence confirms the user's exact raw “开发浏览器已关闭。” error above an indefinite opening placeholder. Main hides/disposes native views on host hide and background cleanup. The work hook permanently skips checks after a manual open, and a queued initial starting update can overwrite manual success. Main only remembers addresses during ensure-work, not when the normal address field opens a page. Presentation errors are surfaced verbatim and survive successful later snapshots. +- Plan: retain successfully opened project URLs in Main for later verified reuse, restore closed views without a new model action, remove the manual-open recovery dead end while ignoring stale requests, handle presentation/closed-state races with bounded retry and actionable student language, then run focused regressions, build and verify the actual preview after hide/show. Preserve existing resource cleanup, draft/project boundaries and original game files. + +- Completed: successful browser open/navigation now records the project/path-bound address in the existing Main preview service. A manual open supersedes older startup checks without preventing future focus/closed-state recovery, and an older pending check cannot later submit an unnecessary Agent prompt. Renderer automatically rechecks on a closed snapshot and retries presentation failures twice, then offers “重新打开作品”. Embedded preview errors no longer surface raw browser diagnostics; successful snapshots clear stale error text. Standalone developer controls retain their behavior. README synchronized. +- Regression evidence: four new hook/panel cases failed on the previous implementation (manual-open focus, queued starting overwrite, closed-state recovery without focus, and presentation-close race). Final focused run passed 53 tests across service, hook, panel and route suites, including pending manual supersession without a late prompt, project/path isolation, bounded retry and explicit recovery. Typecheck, scoped ESLint, production Renderer/Main/Preload/utility build and whitespace checks passed. Both Electron work-preview specs passed: manual address open followed by closed-state recovery without another focus event or a new request identity, preserved drafts, and failure recovery. +- Native verification: both inputs were empty before replacing the isolated trial. Multiple live Electron previews shared the same bundle ID, so CUA resolved the other teacher-components demo even when given this worktree's runtime path. No demo conversation/project actions were performed. The trial launcher now clones the existing Electron app into the same temporary trial directory with a distinct trial-only bundle identity; shared dependencies and the installed production app remain unchanged. Successfully launched PID 81381 from the current build/profile and selected the exact trial. Reopened the known running pet page at port 5179 through the normal address field; native screenshot confirms actual “我的同桌是宠物” content, with no raw closed error or stuck loading state. Cmd-H removed the native page; raising the same window restored it automatically, verified in AX and screenshot without another URL entry or model request for restoration. Current account remains signed out; healthy local-preview restoration works independently of teacher/model login. No game actions, game-source changes or credential changes were made. Left the actual Work + teacher layout visible. +- Limit: remembered page URLs remain scoped to the current Main process, as before. Full application restarts still need the existing first-open Agent path or an explicit known address; this follow-up fixes closed-view/sleep recovery and does not promise persistence of unsaved in-page JavaScript state across a native view recreation. +- Promotion candidate: classroom preview documentation should state that manual and Agent-opened pages both participate in automatic project-scoped restoration, stale startup requests cannot override a ready work view, and transient presentation errors resolve automatically or through one student-facing retry action. Evidence: reproduced failures, 53 focused cases, 2 Electron cases and actual hide/show recovery while signed out. No change to resource cleanup, authentication or model-request authorization; no conflicting decision requiring approval. Workflow lesson: verify the exact native app source and use an isolated trial runtime identity when multiple Electron demos are open; a successful fixture alone does not prove the user's current preview is visible. + +## Main Integration Handoff + +- User explicitly requests merging the finished classroom/teacher/preview changes into main. Resumed this same owned feature task to prepare its commit; current registry/branch/base and peer scope rechecked, previously loaded project context remains applicable, gate passed. No subagents. All listed source changes belong to this accumulated teacher-presence task. +- Main is still at e5d271b and contains 19 unrelated uncommitted legacy consensus files (13 tracked edits and 6 untracked files). A read-only copy of original files, binary patch, index patch and SHA-256 manifest was saved outside the repository at `/Users/chillishark/Makelore 麦洛/合并前备份/20260923-main-consensus`; original files remain untouched. Main checkout cleanup/adoption requires a user decision before final fast-forward. +- The local `scripts/launch-teacher-trial.py` helper contains this machine's disposable profile path and stays outside the product commit. The actual product, tests, pixel asset and task record form the feature handoff; the running trial and its local helper remain available. + +- Final source handoff validation: all 17 changed/new unit suites passed (314 tests). Earlier typecheck, scoped ESLint, production build, responsive/Electron regressions and native hide/show checks remain applicable; this handoff changes no product code. Source commit excludes only the machine-local trial launcher. diff --git a/README.md b/README.md index b857e662..9d9348c7 100644 --- a/README.md +++ b/README.md @@ -152,12 +152,18 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 - 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列正常在 `agent_settled` 后释放,用户可中止当前运行。若 Pi 已终结但该握手丢失,Main 会在 30 秒宽限后仅探测目标线程:权威 idle 状态会补齐持久化最终回复并 exactly-once 释放 ownership,矛盾状态则显式失败该目标,且不会重放已接受请求。select/confirm/input/editor 交互在输入区上方回答,select 除预设选项外支持自由输入;交互提交或取消后立即从输入区移除,失效请求会就地提示并允许重试或取消。 - 隐藏 Conversation 的红色新消息标记只在出现新的待回答交互,或当前任务进入 completed、failed、aborted 终态时设置;助手流式文字、思考、工具过程和单个工具失败不会提前触发红点。 -- 核心聊天页左侧显示品牌、项目及其对话;标题栏显示项目与当前 Conversation,并提供老师、朋友入口和必要的中止/恢复按钮。中央“操作对话/作品”切换真实对话与项目浏览器预览,切换时保留操作草稿。项目设置、归档和会话管理保留在左侧。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 +- 核心聊天页左侧显示品牌、项目及其对话;标题栏显示项目与当前 Conversation,并在右上角提供老师、朋友入口和必要的中止/恢复按钮。中央“操作对话/作品”切换真实对话与项目浏览器预览,切换时保留操作草稿。点击老师头像或主动气泡中的聊天入口,会同时打开老师咨询并默认切到“作品”;仍可手动切回操作对话,单纯收到老师气泡不会切换页面。点击“作品”会由 Main 检查已知本地页面:可访问则直接复用,否则向编程 Agent 发送固定的内部打开作品指令,检查现有服务、按项目实际脚本启动并调用 `agent_browser` 展示。当前项目正在制作时先等待;同一启动过程防止重复派发。失败时提供明确恢复入口:登录失效可“登录并继续”,返回相同账号和项目后接着打开;作品未打开可“帮我检查并打开”;普通操作中断可“帮我继续”,先核对已有进度再继续,保留当前输入草稿。仅对连接检查做两次有上限的自动重连并复用请求身份,不自动重放结果不确定的操作。最新失败提供一个恢复入口,空错误不再显示“本次处理失败。”;作品成功打开后收起对应失败提示。后台休眠释放预览窗口后,Main 在本次应用会话内保留项目对应的已验证地址,包括从地址栏或 Agent 成功打开的页面;回到前台或收到关闭状态时自动检查并恢复仍可访问的作品,不重复调用 Agent,也不依赖模型登录。手动打开成功会覆盖旧的启动请求结果,但不关闭后续恢复检查。窗口显示时发生关闭竞态会自动重试两次;仍无法显示则提供“重新打开作品”,不向学生暴露“开发浏览器已关闭”等内部错误,也不无限停留在打开中。内部指令不消耗学生草稿,也不参与对话自动命名,时间线隐藏这条内部指令及“已点击打开作品”操作记录,保留真实的处理结果和错误。学生作品区不展示 Console/Network 调试栏,也不订阅这两个面板的调试事件;Agent 的后台调试能力保留。项目设置、归档和会话管理保留在左侧。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 + +- 普通消息接收成功不显示额外的技术提示;准备、错误和排队状态仍正常反馈。发送状态按请求对应的运行结束收敛,保留期间输入的新草稿;恢复历史时优先以持久消息身份关联,避免相同文本串联到其他请求。 ### 老师与朋友咨询 -- 标题栏的“老师/朋友”由学生主动打开右侧咨询栏,可调整宽度、关闭和重新打开;咨询已属于当前项目,面板不重复展示项目条。老师引导思考、教方法,朋友分享作品体验中的感受、好奇与困惑。打开面板只读取配置和历史,不触发模型。 -- 老师同时支持自由提问与“老师,帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让老师从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。朋友及运营预览原有固定建议仍只填入草稿。界面不提供独立示范页面、“记一下”、共识或自动待办,“我去试一试”只返回操作输入区,由学生自己表达和实施。 +- Code 课堂左侧项目栏默认宽 171px,可拖动右边缘在 128–256px 间调整,双击恢复默认并在本机记住宽度;标题栏同步对齐。项目名与会话名超长省略并可悬停查看;右侧咨询栏默认宽 508px,可拖动至 640px,紧凑窗口仍使用覆盖式咨询。 +- 老师以统一的像素头像与运营配置的名字显示在右上角,操作对话和作品切换时都可见;标题栏、学生老师咨询与主动发言使用同一形象,运营草稿预览仍展示其配置头像。点击老师头像或名字打开右侧咨询并聚焦输入。朋友保留标题栏入口。咨询栏可调整宽度、关闭和重新打开;打开本身只读取配置和历史,不触发模型。 +- 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原老师对话查看全文。不提供“老师偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 +- 学生在当前项目创作时,前端每 5 分钟请求一次老师跟进;窗口隐藏、来源归档、主对话正在执行、老师正在回复或学生正在老师栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,无新上下文不重复调用。跟进沿用运营模型、教学 Skills 与当前老师话题,生成简短的具体建议或引导;以主动老师发言持久化,不伪造学生消息。模型调用沿用老师计费规则。 +- 进入项目时,老师头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立老师栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和老师聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;老师咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 +- 老师同时支持自由提问与输入框底部的提示词按钮“老师帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让老师从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。朋友及运营预览原有固定建议仍只填入草稿。界面不提供独立示范页面、“记一下”、共识或自动待办,老师和朋友的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 老师保留运营发布的提示词、文本教学 Skills、模型和原生思考选项。首期朋友使用同一发布版本的模型、预算与启停配置,采用独立的体验交流提示词,不继承老师 Skills;具体教学方向和角色提示词后续另行讨论。两者均只读取按预算截取的当前操作对话已完成分支文本及明确引用,不运行工具,也不能声称实际看到了画面或试玩过作品。Main 直接调用既有计费网关,不创建 Pi worker。 - 咨询话题及未发送草稿按当前云端账号、项目与角色分别保存;切换操作对话仍延续该项目下的咨询,每轮发送时绑定当时的操作对话来源。创建话题时固定发布版本,每轮重新检查启停。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。没有操作对话时也可咨询,但无操作文本上下文。 - 删除操作对话会停止正在使用该来源的回复,保留项目级咨询历史;归档来源需先恢复后才能引用。原有来源会话级老师 API 与存储保持兼容,原路径历史不自动迁移到新项目级咨询栏。运营草稿试聊仍使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。 diff --git a/electron/api/routes/agent-browser.ts b/electron/api/routes/agent-browser.ts index 8422b702..0a1e6abc 100644 --- a/electron/api/routes/agent-browser.ts +++ b/electron/api/routes/agent-browser.ts @@ -2,11 +2,14 @@ import { realpath } from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; import type { AgentBrowserBounds, AgentBrowserFaultShape } from '../../../shared/agent-browser'; import { normalizeCodingProjectPath } from '../../coding-projects/project-store'; +import { CodingWorkPreviewService } from '../../coding-runtime/work-preview'; import type { HostApiContext } from '../context'; import { hasRendererCapability } from '../renderer-capability'; import { parseJsonBody, sendJson } from '../route-utils'; type AgentBrowserBody = { + conversation_id?: unknown; + request_id?: unknown; project_id?: unknown; project_path?: unknown; url?: unknown; @@ -28,6 +31,23 @@ type AgentBrowserBody = { max_bytes?: unknown; }; +const workPreviewServices = new WeakMap(); + +function workPreviewService(ctx: HostApiContext): CodingWorkPreviewService | undefined { + if (!ctx.codingProducts || !ctx.agentBrowser) return undefined; + let preview = workPreviewServices.get(ctx); + if (!preview) { + preview = new CodingWorkPreviewService({ + browser: ctx.agentBrowser, + conversations: ctx.codingProducts.conversations, + runtime: ctx.codingProducts.runtime, + ensureActive: (target) => ensureProjectStillActive(ctx, target), + }); + workPreviewServices.set(ctx, preview); + } + return preview; +} + class AgentBrowserRouteError extends Error { constructor( readonly code: AgentBrowserFaultShape['code'], @@ -236,6 +256,27 @@ export async function handleAgentBrowserRoutes( try { const service = requireService(ctx); + if (url.pathname === '/api/agent-browser/ensure-work' && req.method === 'POST') { + requireRendererPresentation(req); + const body = await readBody(req); + const project = await resolveActiveProject(ctx, undefined, body.project_id, true); + const requestId = nonEmptyString(body.request_id); + if (!requestId || !/^[a-zA-Z0-9-]{1,80}$/.test(requestId)) { + throw new AgentBrowserRouteError('INVALID_REQUEST', '作品请求无效。'); + } + if (!ctx.codingProducts) throw new AgentBrowserRouteError('CLOSED', '本地编程服务暂时不可用。', 503); + const preview = workPreviewService(ctx)!; + const release = ctx.lifecycle?.acquireLease({ id: `work-preview:${project.id}:${requestId}`, kind: 'work-preview' }); + try { + const result = await preview.ensure(project, requestId, nonEmptyString(body.conversation_id)); + await ensureProjectStillActive(ctx, project); + sendJson(res, 200, { success: true, ...result }); + } finally { + release?.(); + } + return true; + } + if (url.pathname === '/api/agent-browser/state' && req.method === 'GET') { const project = await resolveActiveProject( ctx, @@ -272,6 +313,7 @@ export async function handleAgentBrowserRoutes( ...(body.inject_project_data === true ? { injectProjectData: true } : {}), }); await ensureProjectStillActive(ctx, project); + workPreviewService(ctx)?.remember(project, browser); emitState(ctx, 'agent-browser:show', browser); sendJson(res, 200, { success: true, browser }); return true; @@ -318,6 +360,7 @@ export async function handleAgentBrowserRoutes( url: nonEmptyString(body.url), }); await ensureProjectStillActive(ctx, project); + workPreviewService(ctx)?.remember(project, browser); emitState(ctx, 'agent-browser:state', browser); sendJson(res, 200, { success: true, browser }); return true; diff --git a/electron/api/routes/coding-teacher.ts b/electron/api/routes/coding-teacher.ts index d6d70bc1..09423117 100644 --- a/electron/api/routes/coding-teacher.ts +++ b/electron/api/routes/coding-teacher.ts @@ -8,7 +8,7 @@ import { } from '../route-utils'; import { TeacherError } from '../../coding-teacher/config-client'; import type { TeacherScope } from '../../coding-teacher/service'; -import type { TeacherSend } from '../../../shared/coding-teacher'; +import type { TeacherCheckInInput, TeacherSend } from '../../../shared/coding-teacher'; import { takeTeacherPreviewRevision } from '../../main/app-deep-link'; export async function handleCodingTeacherRoutes( @@ -26,11 +26,12 @@ export async function handleCodingTeacherRoutes( const projectTopics = url.pathname.match( /^\/api\/coding\/projects\/([^/]+)\/(teacher|friend)-topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/ ); + const checkIn = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/teacher-check-in$/); const role = projectTopics?.[2] === 'friend' || url.pathname === '/api/coding/friend/config' ? 'friend' : 'teacher'; const config = url.pathname === '/api/coding/teacher/config' || url.pathname === '/api/coding/friend/config'; const draft = url.pathname === '/api/coding/teacher-preview'; const pending = url.pathname === '/api/coding/teacher-preview/pending-link'; - if (!source && !preview && !projectTopics && !config && !draft && !pending) return false; + if (!source && !preview && !projectTopics && !checkIn && !config && !draft && !pending) return false; if ((config || draft || pending) && req.method !== 'GET') { sendJson(res, 405, { error: '不支持此操作。' }); return true; @@ -45,6 +46,14 @@ export async function handleCodingTeacherRoutes( return true; } try { + if (checkIn) { + if (req.method !== 'POST') sendJson(res, 405, { error: '不支持此操作。' }); + else sendJson(res, 200, await service.checkIn( + { projectId: decodeURIComponent(checkIn[1]), sourceId: 'project', role: 'teacher' }, + await parseJsonBody(req) + )); + return true; + } if (config && req.method === 'GET') { sendJson(res, 200, await service.definition(role)); return true; @@ -74,7 +83,7 @@ export async function handleCodingTeacherRoutes( return true; } if (id && !action && req.method === 'GET') { - sendJson(res, 200, await service.read(scope, id)); + sendJson(res, 200, await service.read(scope, id, url.searchParams.get('select') !== 'false')); return true; } if (id && action === 'messages' && req.method === 'POST') { diff --git a/electron/coding-runtime/conversation-title.ts b/electron/coding-runtime/conversation-title.ts index eb16c78d..4a3cc9d1 100644 --- a/electron/coding-runtime/conversation-title.ts +++ b/electron/coding-runtime/conversation-title.ts @@ -1,9 +1,12 @@ import type { ConversationNode } from './contracts'; +import { OPEN_WORK_PROMPT } from '../../shared/coding-work-preview'; +import { CONTINUE_CODING_PROMPT } from '../../shared/coding-recovery'; /** Only real user messages name a conversation; no model call or tool output. */ export function firstMessageTitle(node: ConversationNode): string | null { if (node.kind !== 'message' || node.role !== 'user' || node.status !== 'complete') return null; const text = node.blocks.flatMap((block) => block.kind === 'text' ? [block.text] : []).join('\n').trim(); + if (text === OPEN_WORK_PROMPT || text === CONTINUE_CODING_PROMPT) return null; if (text.startsWith('/')) return null; const firstLine = text.split(/\r?\n/)[0].replace(/\s+/g, ' ').trim(); return [...firstLine].slice(0, 32).join('') diff --git a/electron/coding-runtime/pi/provider-failure.ts b/electron/coding-runtime/pi/provider-failure.ts index 346a5e45..323dac08 100644 --- a/electron/coding-runtime/pi/provider-failure.ts +++ b/electron/coding-runtime/pi/provider-failure.ts @@ -3,10 +3,11 @@ import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error- import { getAIGatewayErrorKind } from '../../../shared/ai-gateway-error-kind'; export function projectPiProviderFailure(message: unknown): CodingRuntimePublicError { - if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) { + if (typeof message === 'string' && (isAIGatewayUserContextMissing(message) + || message.toLowerCase().includes('works square login session is missing or expired'))) { return { code: 'CODING_PROVIDER_AUTH_REQUIRED', - message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。', + message: '登录已失效,请重新登录后继续。', recoverable: true, }; } diff --git a/electron/coding-runtime/pi/session-projector.ts b/electron/coding-runtime/pi/session-projector.ts index b27d7a79..9dbf6c6b 100644 --- a/electron/coding-runtime/pi/session-projector.ts +++ b/electron/coding-runtime/pi/session-projector.ts @@ -321,8 +321,10 @@ function reconcileLiveIds( if (node.kind === 'message') { const live = liveNodes.find((candidate) => candidate.kind === 'message' && !used.has(candidate.id) - && (candidate.sourceEntryId === node.sourceEntryId - || messageSignature(candidate) === messageSignature(node))); + && Boolean(node.sourceEntryId) && candidate.sourceEntryId === node.sourceEntryId) + ?? liveNodes.find((candidate) => candidate.kind === 'message' + && !used.has(candidate.id) && !candidate.sourceEntryId + && messageSignature(candidate) === messageSignature(node)); if (!live || live.kind !== 'message') return node; used.add(live.id); return { @@ -383,12 +385,16 @@ export async function projectPiSessionSnapshot( const path = activePath(response.entries, response.leafId); const durableNodes = await projectEntries(path, input); const nodes = reconcileLiveIds(durableNodes, input.snapshot.nodes); + const preserveTerminalRun = input.workerGeneration === input.snapshot.cursor.workerGeneration + && Boolean(input.snapshot.run.runId) + && (input.snapshot.run.status === 'error' + || (input.snapshot.run.status === 'idle' && input.snapshot.run.terminalReason !== undefined)); return { ...structuredClone(input.snapshot), nodes, run: state.isStreaming === true ? { ...structuredClone(input.snapshot.run), status: 'running' } - : { status: 'idle' }, + : preserveTerminalRun ? structuredClone(input.snapshot.run) : { status: 'idle' }, queue: { items: [] }, context: projectedContext(input, state), pendingInteractions: [], diff --git a/electron/coding-runtime/work-preview.ts b/electron/coding-runtime/work-preview.ts new file mode 100644 index 00000000..e12c2f45 --- /dev/null +++ b/electron/coding-runtime/work-preview.ts @@ -0,0 +1,148 @@ +import type { AgentBrowserService } from '../api/context'; +import type { CodingConversationService } from './conversation-service'; +import type { CodingConversationRuntime } from './contracts'; +import { OPEN_WORK_PROMPT, type WorkPreviewResult } from '../../shared/coding-work-preview'; +import { codingRecovery } from '../../shared/coding-recovery'; +import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; + +type Project = { id: string; path: string }; +type Attempt = { + requestId: string; + conversationId?: string; + submitted?: boolean; + runId?: string; + submittedAt?: number; + result?: WorkPreviewResult; +}; + +/** Only probe a known loopback work address; never scan ports or follow redirects. */ +export async function workPageResponds(address: string): Promise { + try { + const url = new URL(address); + if (!['http:', 'https:'].includes(url.protocol) + || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + || url.username || url.password) return false; + const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) }); + await response.body?.cancel(); + return response.ok; + } catch { + return false; + } +} + +export class CodingWorkPreviewService { + private readonly attempts = new Map(); + private readonly flights = new Map>(); + // Native views are disposable during background sleep; the running project + // server and its verified address have a separate lifetime. + private readonly pages = new Map(); + + constructor(private readonly deps: { + browser: Pick; + conversations: Pick; + runtime: Pick; + ensureActive(project: Project): Promise; + responds?(url: string): Promise; + }) {} + + remember(project: Project, snapshot: AgentBrowserSnapshot): void { + if (snapshot.projectId !== project.id || !snapshot.url || !snapshot.browserId + || snapshot.state !== 'attached' || snapshot.error) return; + this.pages.delete(project.id); + if (this.pages.size >= 100) this.pages.delete(this.pages.keys().next().value!); + this.pages.set(project.id, { path: project.path, url: snapshot.url }); + this.attempts.delete(project.id); + // A successful manual/Agent open supersedes a slow older startup check. + this.flights.delete(project.id); + } + + async ensure(project: Project, requestId: string, conversationId?: string): Promise { + await this.deps.ensureActive(project); + // Serialize browser checks as well as prompt submission for each project. + const existing = this.flights.get(project.id); + if (existing) return existing; + const flight = this.check(project, requestId, conversationId); + this.flights.set(project.id, flight); + try { return await flight; } + finally { if (this.flights.get(project.id) === flight) this.flights.delete(project.id); } + } + + private async check(project: Project, requestId: string, conversationId?: string): Promise { + const { browser, conversations } = this.deps; + const snapshot = await browser.getSnapshot(project.path); + const responds = this.deps.responds ?? workPageResponds; + const remembered = this.pages.get(project.id); + const address = snapshot.projectId === project.id && snapshot.url + ? snapshot.url + : remembered?.path === project.path ? remembered.url : undefined; + if (address && await responds(address)) { + await this.deps.ensureActive(project); + const ready = snapshot.projectId === project.id && snapshot.url === address + && snapshot.browserId && snapshot.state === 'attached' && !snapshot.error + ? snapshot + : await browser.open({ projectId: project.id, projectPath: project.path, url: address, visible: false }); + await this.deps.ensureActive(project); + this.remember(project, ready); + return { status: 'ready', browser: ready }; + } + if (this.pages.has(project.id) && this.pages.get(project.id) !== remembered) return this.check(project, requestId, conversationId); + this.pages.delete(project.id); + await this.deps.ensureActive(project); + let attempt = this.attempts.get(project.id); + if (attempt?.submitted && attempt.conversationId) { + const current = await conversations.getSnapshot(attempt.conversationId); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); + if (!['idle', 'error'].includes(current.run.status)) { + return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) }; + } + if (attempt.runId && current.run.runId !== attempt.runId && Date.now() - attempt.submittedAt! < 15_000) { + return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) }; + } + // The accepted action settled without opening a reachable work page. + const errorCode = current.run.error?.code; + attempt.result ??= { status: 'failed', errorCode, message: codingRecovery(errorCode, true).message }; + } + if (attempt?.result && attempt.requestId === requestId) return attempt.result; + if (!attempt || attempt.result || attempt.requestId !== requestId) { + attempt = { requestId, conversationId }; + // Keep only a bounded number of inactive project attempts. + if (this.attempts.size >= 100) this.attempts.delete(this.attempts.keys().next().value!); + this.attempts.set(project.id, attempt); + } + + const list = await conversations.listConversations(project.id); + const projectIds = new Set(list.map(({ id }) => id)); + const busy = this.deps.runtime.getDiagnostics().workers.some((worker) => ( + projectIds.has(worker.conversationId) && ['starting', 'queued', 'running'].includes(worker.stage) + )); + if (busy) return { status: 'waiting' }; + let conversation = attempt.conversationId + ? await conversations.getConversation(attempt.conversationId) + : list.find((item) => !item.archivedAt); + await this.deps.ensureActive(project); + conversation ??= await conversations.createConversation({ projectId: project.id, title: '新对话' }); + attempt.conversationId = conversation.id; + const current = await conversations.getSnapshot(conversation.id); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); + if (!['idle', 'error'].includes(current.run.status)) return { status: 'waiting', conversation }; + await this.deps.ensureActive(project); + // Mark before dispatch: uncertain acceptance must never cause automatic resubmission. + attempt.submitted = true; + attempt.submittedAt = Date.now(); + try { + const acceptance = await conversations.acceptPrompt({ + conversationId: conversation.id, + clientRequestId: `work-preview-${attempt.requestId}`, + mode: 'prompt', + text: OPEN_WORK_PROMPT, + }); + attempt.runId = acceptance.runId; + } catch (error) { + const errorCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code : 'CODING_REQUEST_UNCERTAIN'; + attempt.result = { status: 'failed', conversation, errorCode, message: codingRecovery(errorCode, true).message }; + return attempt.result; + } + return { status: 'starting', conversation }; + } +} diff --git a/electron/coding-teacher/context.ts b/electron/coding-teacher/context.ts index 65cc1cce..e54b5d83 100644 --- a/electron/coding-teacher/context.ts +++ b/electron/coding-teacher/context.ts @@ -61,8 +61,10 @@ export function compileTeacherContext( ].join('\n\n'), }; const current: TeacherModelMessage = { - role: 'user', - content: [ + role: intent === 'check-in' ? 'system' : 'user', + content: intent === 'check-in' + ? '本轮是老师定时主动关心,不是学生提问。依据来源操作对话中已完成的文字和老师咨询历史,自然地说一段简短中文关心、具体建议或思考引导,约 120 字,最多问一个问题,不要求学生立即回答。只围绕已有证据,不重复刚说过的内容,不整理待办、不替学生作决定;没有实际看到或操作作品,不能假装看到了画面、运行或试玩过作品。直接说给学生听,不提定时检查、系统触发等技术过程。' + : [ ...references.map( (ref) => '明确引用' + @@ -97,13 +99,13 @@ export function compileTeacherContext( }, ] : []), - ...exchanges.flatMap((request) => [ - { + ...exchanges.flatMap((request): TeacherModelMessage[] => [ + ...(request.intent === 'check-in' ? [] : [{ role: 'user' as const, content: [...request.references.map((ref) => '明确引用:\n' + ref.text), request.text].join( '\n\n' ), - }, + }]), { role: 'assistant' as const, content: [ @@ -121,8 +123,7 @@ export function compileTeacherContext( omitted++; } while (estimateTeacherTokens(build()) > maxInputTokens && exchanges.length > 1) { - exchanges.shift(); - omitted += 2; + omitted += exchanges.shift()?.intent === 'check-in' ? 1 : 2; } const messages = build(); if (estimateTeacherTokens(messages) > maxInputTokens) diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index e7b36676..782ceedb 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { readdir, rm } from 'node:fs/promises'; import path from 'node:path'; import { EventEmitter } from 'node:events'; @@ -6,12 +6,15 @@ import type { CodingProjectService } from '../coding-projects/project-service'; import type { CodingConversationRuntime } from '../coding-runtime/contracts'; import type { ConsultationRole, + TeacherCheckInInput, + TeacherCheckInResult, TeacherDefinition, TeacherReference, TeacherSend, TeacherSourceContext, TeacherTopic, } from '../../shared/coding-teacher'; +import { TEACHER_CHECK_IN_INTERVAL_MS, TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS } from '../../shared/coding-teacher'; import { currentTeacherAccount, assertTeacherAccount, @@ -55,7 +58,7 @@ export class CodingTeacherService { private readonly tails = new Map>(); private readonly active = new Map< string, - { account: TeacherAccount; controller: AbortController; sourceId?: string; projectId: string } + { account: TeacherAccount; controller: AbortController; sourceId?: string; projectId: string; role: ConsultationRole } >(); private readonly finishes = new Map>(); private readonly deletingSources = new Set(); @@ -119,6 +122,9 @@ export class CodingTeacherService { private key(account: TeacherAccount, scope: TeacherScope, id: string) { return account.id + ':' + scope.projectId + ':' + scope.sourceId + ':' + (scope.role ?? 'teacher') + ':' + id; } + private acceptanceKey(account: TeacherAccount, scope: TeacherScope) { + return account.id + ':' + scope.projectId + ':teacher-acceptance'; + } private async serialize(key: string, operation: () => Promise): Promise { const previous = this.tails.get(key) ?? Promise.resolve(); const next = previous.catch(() => undefined).then(operation); @@ -196,23 +202,102 @@ export class CodingTeacherService { throw new TeacherError(404, 'teacher_topic_not_found', '老师话题不存在。'); return { store, topic }; } - async read(scope: TeacherScope, id: string) { + async read(scope: TeacherScope, id: string, select = true) { const account = await this.account(); const { store, topic } = await this.readOwned(account, scope, id); - await store.select(id); + if (select) await store.select(id); return structuredClone(topic); } async send(scope: TeacherScope, id: string, input: TeacherSend): Promise { + // Background turns must pass the project-wide cooldown and source checks. + if (input.intent === 'check-in') + throw new TeacherError(422, 'teacher_intent_invalid', '主动关心只能由项目老师检查发起。'); + const account = await this.account(); + if (scope.projectId !== 'preview' && (scope.role ?? 'teacher') === 'teacher') + return await this.serialize(this.acceptanceKey(account, scope), () => this.sendRequest(account, scope, id, input)); + return await this.sendRequest(account, scope, id, input); + } + async checkIn(scope: TeacherScope, input: TeacherCheckInInput): Promise { + if (scope.projectId === 'preview' || scope.sourceId !== 'project' || (scope.role ?? 'teacher') !== 'teacher') + throw new TeacherError(422, 'teacher_intent_invalid', '主动关心只适用于项目里的老师。'); + teacherTopicId(input.requestId); + teacherTopicId(input.sourceConversationId); + const account = await this.account(); + return await this.serialize(this.acceptanceKey(account, scope), async () => { + this.assertAccount(account); + const store = await this.scopedStore(account, scope); + const list = await store.list(); + const topics = await Promise.all(list.items.map(async (item) => (await this.readOwned(account, scope, item.id)).topic)); + for (const topic of topics) { + const existing = topic.requests.find((request) => request.id === input.requestId); + if (!existing) continue; + if (existing.intent !== 'check-in' || existing.sourceConversationId !== input.sourceConversationId) + throw new TeacherError(409, 'teacher_request_conflict', '同一请求标识不能用于不同问题。'); + this.assertAccount(account); + return { topic: structuredClone(topic) }; + } + if ([...this.active.values()].some((run) => run.account.id === account.id + && run.projectId === scope.projectId && run.role === 'teacher') + || topics.some((topic) => topic.requests.some((request) => ['preparing', 'running'].includes(request.status)))) + return { topic: null, skipped: 'busy' }; + const checks = topics.flatMap((topic) => topic.requests.filter((request) => request.intent === 'check-in')); + if (checks.some((request) => Date.now() - Date.parse(request.createdAt) < TEACHER_CHECK_IN_INTERVAL_MS)) + return { topic: null, skipped: 'cooldown' }; + if (this.deletingSources.has(scope.projectId + ':' + input.sourceConversationId)) + throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); + const project = await this.options.projects.getProject(scope.projectId); + const conversation = await this.options.projects.conversationStore(project.path).get(input.sourceConversationId); + if (!conversation) + throw new TeacherError(404, 'teacher_source_not_found', '来源会话不属于当前项目。'); + if (conversation.archivedAt) return { topic: null, skipped: 'archived' }; + const available = await (this.options.availability ?? teacherAvailability)(account); + this.assertAccount(account); + if (!available.enabled || !available.published_version) return { topic: null, skipped: 'disabled' }; + const source = await (this.options.readSource?.({ ...scope, sourceId: input.sourceConversationId }) + ?? readTeacherSource(this.options.projects, this.options.runtime, this.options.userDataDir, scope.projectId, input.sourceConversationId)); + this.assertAccount(account); + const selectedTopic = topics.find((candidate) => candidate.id === list.lastSelectedTopicId); + // Student discussion is progress too; proactive replies themselves must not + // change this digest and cause another identical check-in five minutes later. + const discussion = (selectedTopic?.requests ?? []) + .filter((request) => request.intent !== 'check-in' && request.status === 'completed') + .map(({ id, text, response }) => [id, text, response]) + .sort((a, b) => a[0].localeCompare(b[0])); + if (!source.messages.some((message) => message.text.trim()) && discussion.length === 0) + return { topic: null, skipped: 'no-context' }; + const fingerprint = createHash('sha256') + .update(JSON.stringify({ source: source.messages.map(({ id, role, text }) => [id, role, text]), discussion })) + .digest('hex'); + if (checks.some((request) => request.sourceConversationId === input.sourceConversationId + && request.status === 'completed' + && request.checkInSourceFingerprint === fingerprint + && Date.now() - Date.parse(request.createdAt) < TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS)) + return { topic: null, skipped: 'unchanged' }; + const topic = selectedTopic ?? await this.create(scope); + return { + topic: await this.sendRequest(account, scope, topic.id, { + ...input, intent: 'check-in', text: '', + }, { source, fingerprint }), + }; + }); + } + private async sendRequest( + account: TeacherAccount, + scope: TeacherScope, + id: string, + input: TeacherSend, + checkIn?: { source: TeacherSourceContext; fingerprint: string } + ): Promise { teacherTopicId(input.requestId); const intent = input.intent === undefined ? 'question' : input.intent; - if (!['question', 'suggestions', 'guided-help'].includes(intent)) + if (!['question', 'suggestions', 'guided-help', 'check-in'].includes(intent) || (intent === 'check-in' && !checkIn)) throw new TeacherError(422, 'teacher_intent_invalid', '提问方式无效,请重新打开老师后再试。'); if (intent !== 'question' && ( scope.projectId === 'preview' || scope.sourceId !== 'project' || (scope.role ?? 'teacher') !== 'teacher' )) throw new TeacherError(422, 'teacher_intent_invalid', '这种提问方式只适用于项目里的老师。'); if (input.sourceConversationId !== undefined) teacherTopicId(input.sourceConversationId); - if (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 6000) + if (typeof input.text !== 'string' || (intent !== 'check-in' && !input.text.trim()) || input.text.length > 6000) throw new TeacherError(422, 'teacher_question_invalid', '请输入 1–6000 字的问题。'); const refs = input.references ?? []; if ( @@ -228,8 +313,7 @@ export class CodingTeacherService { refs.reduce((n, ref) => n + ref.text.length, 0) > 12000 ) throw new TeacherError(422, 'teacher_reference_invalid', '引用内容无效或超过 12000 字。'); - const account = await this.account(), - key = this.key(account, scope, id); + const key = this.key(account, scope, id); return await this.serialize(key, async () => { const { store, topic } = await this.readOwned(account, scope, id); const existing = topic.requests.find((request) => request.id === input.requestId); @@ -262,11 +346,14 @@ export class CodingTeacherService { throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); if (scope.sourceId === 'project' && sourceId) { const project = await this.options.projects.getProject(scope.projectId); - if (!(await this.options.projects.conversationStore(project.path).get(sourceId))) + const conversation = await this.options.projects.conversationStore(project.path).get(sourceId); + if (!conversation) throw new TeacherError(404, 'teacher_source_not_found', '来源会话不属于当前项目。'); + if (checkIn && conversation.archivedAt) + throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。'); } const source: TeacherSourceContext = - scope.projectId === 'preview' + checkIn ? checkIn.source : scope.projectId === 'preview' ? { messages: topic.sampleContext ? [{ id: 'preview', role: 'user', text: topic.sampleContext }] @@ -304,6 +391,7 @@ export class CodingTeacherService { account, topic.definition ); + if (checkIn) this.assertAccount(account); const compiled = compileTeacherContext( topic.definition, source, @@ -317,10 +405,20 @@ export class CodingTeacherService { // is being deleted. Project consultations must recheck the actual source. if (sourceId && this.deletingSources.has(scope.projectId + ':' + sourceId)) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); + if (checkIn && sourceId) { + const project = await this.options.projects.getProject(scope.projectId); + const conversation = await this.options.projects.conversationStore(project.path).get(sourceId); + if (!conversation) + throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); + if (conversation.archivedAt) + throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。'); + this.assertAccount(account); + } const request = { id: input.requestId, intent, ...(input.sourceConversationId ? { sourceConversationId: input.sourceConversationId } : {}), + ...(checkIn ? { checkInSourceFingerprint: checkIn.fingerprint } : {}), text: input.text, references, createdAt: new Date().toISOString(), @@ -345,7 +443,7 @@ export class CodingTeacherService { // than leaving a permanently preparing request in the project topic. if (sourceId && this.deletingSources.has(scope.projectId + ':' + sourceId)) controller.abort(); - this.active.set(key, { account, controller, sourceId, projectId: scope.projectId }); + this.active.set(key, { account, controller, sourceId, projectId: scope.projectId, role: scope.role ?? 'teacher' }); const release = this.options.acquireLease?.(key) ?? (() => undefined); const finish = async () => { const current = topic.requests.at(-1)!; diff --git a/electron/coding-teacher/store.ts b/electron/coding-teacher/store.ts index 16fdd849..9438ab4b 100644 --- a/electron/coding-teacher/store.ts +++ b/electron/coding-teacher/store.ts @@ -33,7 +33,8 @@ export class TeacherTopicStore { const items = topics .map((topic) => ({ id: topic.id, - title: topic.requests[0]?.text.slice(0, 32) || '新话题', + title: topic.requests.find((request) => request.intent !== 'check-in')?.text.slice(0, 32) + || (topic.requests.some((request) => request.intent === 'check-in') ? '和老师聊聊' : '新话题'), updatedAt: topic.updatedAt, version: topic.version, })) diff --git a/shared/coding-recovery.ts b/shared/coding-recovery.ts new file mode 100644 index 00000000..d5a3d6a8 --- /dev/null +++ b/shared/coding-recovery.ts @@ -0,0 +1,22 @@ +export type CodingRecoveryAction = 'login' | 'continue' | 'check-work' | 'refresh' | 'settings' | 'account'; + +/** Product copy is intentionally independent of raw provider/tool diagnostics. */ +export function codingRecovery(errorCode?: string, work = false) { + if (errorCode === 'CODING_PROVIDER_AUTH_REQUIRED' || errorCode === 'AUTH_INVALID') { + return { action: 'login' as const, label: '登录并继续', message: '登录已失效,重新登录后就能接着完成这一步。' }; + } + if (errorCode === 'CODING_PROVIDER_QUOTA_EXHAUSTED') { + return { action: 'account' as const, label: '查看点数', message: '点数暂时不够了,可以请家长帮忙补充后再继续。' }; + } + if (errorCode === 'CODING_MODEL_UNAVAILABLE' || errorCode === 'CODING_MIGRATION_MODEL_REQUIRED') { + return { action: 'settings' as const, label: '选择可用模型', message: '还需要选择一个可用模型,选好后就能继续。' }; + } + if (errorCode === 'CODING_REQUEST_UNCERTAIN') { + return { action: 'refresh' as const, label: '查看最新进展', message: '还没有收到完成消息,先查看进展,避免重复操作。' }; + } + return work + ? { action: 'check-work' as const, label: '帮我检查并打开', message: '作品还没有打开,我可以检查启动情况,再帮你打开。' } + : { action: 'continue' as const, label: '帮我继续', message: '这一步还没完成。可以让我检查已有进度,再接着做。' }; +} + +export const CONTINUE_CODING_PROMPT = '请检查上一次未完成的操作和当前项目的实际进度,再接着完成。已完成的步骤不要重复执行;如果缺少必要信息,直接告诉我需要补充什么。'; diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 925cbe4e..4c1a4af1 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -2,7 +2,9 @@ import type { ManagedReasoningChoice } from './managed-model-capabilities'; import type { PublicUsage } from './coding-conversation-contracts'; export type ConsultationRole = 'teacher' | 'friend'; -export type TeacherRequestIntent = 'question' | 'suggestions' | 'guided-help'; +export const TEACHER_CHECK_IN_INTERVAL_MS = 5 * 60 * 1000; +export const TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS = 15 * 60 * 1000; +export type TeacherRequestIntent = 'question' | 'suggestions' | 'guided-help' | 'check-in'; export interface TeacherDefinition { schema_version: 1; @@ -55,6 +57,8 @@ export type TeacherRequestStatus = export interface TeacherRequest { intent?: TeacherRequestIntent; sourceConversationId?: string; + /** Digest of completed source text, retained for check-in deduplication across restarts. */ + checkInSourceFingerprint?: string; id: string; text: string; references: TeacherReference[]; @@ -96,3 +100,11 @@ export interface TeacherSend { text: string; references?: TeacherReference[]; } +export interface TeacherCheckInInput { + requestId: string; + sourceConversationId: string; +} +export interface TeacherCheckInResult { + topic: TeacherTopic | null; + skipped?: 'busy' | 'cooldown' | 'unchanged' | 'no-context' | 'archived' | 'disabled'; +} diff --git a/shared/coding-work-preview.ts b/shared/coding-work-preview.ts new file mode 100644 index 00000000..fba6cb3c --- /dev/null +++ b/shared/coding-work-preview.ts @@ -0,0 +1,19 @@ +import type { AgentBrowserSnapshot } from './agent-browser'; +import type { CodingConversationMetadata } from './coding-project-contracts'; + +/** Main supplies this product action; it never consumes the student's draft. */ +export const OPEN_WORK_PROMPT = `【系统操作:打开作品】 +学生点击了“作品”,请帮学生把当前项目已有的作品页面打开。 +1. 先检查当前项目、已有终端/服务和 agent_browser 的状态。若作品服务已正常运行,直接复用,不重复启动、不重启、不更换端口。 +2. 若尚未运行,阅读项目实际文件、README 和启动脚本,选择已有的包管理器及启动方式;静态页面使用合适的本地静态服务。只执行打开已有作品所需的准备和启动,不擅自新增功能或重写作品。 +3. 长时间运行的服务应在后台保持运行,保存必要的进程和日志信息,避免启动命令一直阻塞当前对话。等待页面实际可以访问,确认是当前项目的作品,再调用 agent_browser(action: "open", url: 实际可访问地址)把页面打开到作品区。不要只给学生一个链接或要求学生复制命令。 +4. 若还没有作品、缺少必要配置或启动失败,停止无效重试,用孩子能懂的一两句话说明情况和下一步。不要编造成功、页面或运行结果。 +这是点击作品触发的内部操作。完成后简短告知结果,不复述本指令。`; + +export interface WorkPreviewResult { + status: 'ready' | 'waiting' | 'starting' | 'failed'; + browser?: AgentBrowserSnapshot; + conversation?: CodingConversationMetadata; + message?: string; + errorCode?: string; +} diff --git a/src/assets/consultations/teacher-pixel.png b/src/assets/consultations/teacher-pixel.png new file mode 100644 index 00000000..be917a42 Binary files /dev/null and b/src/assets/consultations/teacher-pixel.png differ diff --git a/src/components/layout/ClassroomSidebarResizer.tsx b/src/components/layout/ClassroomSidebarResizer.tsx new file mode 100644 index 00000000..099f549a --- /dev/null +++ b/src/components/layout/ClassroomSidebarResizer.tsx @@ -0,0 +1,54 @@ +import { useRef } from 'react'; +import { useSettingsStore } from '@/stores/settings'; +import { + CLASSROOM_SIDEBAR_DEFAULT_WIDTH, + CLASSROOM_SIDEBAR_MIN_WIDTH, + CLASSROOM_SIDEBAR_MAX_WIDTH, + normalizeClassroomSidebarWidth, +} from '@/lib/classroom-layout'; + +export function ClassroomSidebarResizer() { + const width = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth)); + const setWidth = useSettingsStore((state) => state.setClassroomSidebarWidth); + const drag = useRef<{ pointerId: number; x: number; width: number } | null>(null); + return
setWidth(CLASSROOM_SIDEBAR_DEFAULT_WIDTH)} + onPointerDown={(event) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = { pointerId: event.pointerId, x: event.clientX, width }; + }} + onPointerMove={(event) => { + if (drag.current?.pointerId !== event.pointerId) return; + setWidth(drag.current.width + event.clientX - drag.current.x); + }} + onPointerUp={(event) => { + if (drag.current?.pointerId !== event.pointerId) return; + drag.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onLostPointerCapture={() => { drag.current = null; }} + onPointerCancel={() => { drag.current = null; }} + onKeyDown={(event) => { + const next = event.key === 'ArrowRight' ? width + 8 + : event.key === 'ArrowLeft' ? width - 8 + : event.key === 'Home' ? CLASSROOM_SIDEBAR_MIN_WIDTH + : event.key === 'End' ? CLASSROOM_SIDEBAR_MAX_WIDTH : null; + if (next === null) return; + event.preventDefault(); + setWidth(next); + }} + />; +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f3a7c028..6fba2d12 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -40,6 +40,8 @@ import type { SidebarPeekSource } from './sidebar-peek'; import { ModuleSwitcher } from './ModuleSwitcher'; import { ImageWorkspaceSidebar } from './ImageWorkspaceSidebar'; import { SidebarUpdateButton } from './SidebarUpdateButton'; +import { ClassroomSidebarResizer } from './ClassroomSidebarResizer'; +import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout'; import { UserAvatar } from '@/components/profile/UserAvatar'; import { getAccountInitial } from '@/components/profile/user-avatar-utils'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; @@ -86,6 +88,7 @@ export function Sidebar({ }: SidebarProps = {}) { const { t } = useTranslation('common'); const storedSidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); + const sidebarWidth = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth)); const sidebarPinnedCollapsed = !forceExpanded && storedSidebarCollapsed; const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen; const projects = useCodingWorkspaceStore((state) => state.projects); @@ -374,26 +377,30 @@ export function Sidebar({ return (
); })} diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index f426c323..c73241ec 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -12,6 +12,7 @@ import { invokeIpc } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useSettingsStore } from '@/stores/settings'; +import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import type { SidebarPeekSource } from './sidebar-peek'; import { TITLEBAR_LOGO_WIDTH, WINDOWS_TITLEBAR_CONTROLS_WIDTH } from './titlebar-metrics'; @@ -67,6 +68,7 @@ function ProductTitleBar({ onSidebarPeekChange?: SidebarPeekChange; }) { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); + const sidebarWidth = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth)); const activeProject = useCodingWorkspaceStore((state) => state.activeProject); const toolbarButtonClass = 'no-drag motion-press flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-surface-subtle hover:text-foreground'; const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); @@ -153,6 +155,7 @@ function ProductTitleBar({ <>
; + if (typeof item.id !== 'string' || typeof item.account !== 'string' || !item.account + || typeof item.projectId !== 'string' || !item.projectId + || !(item.conversationId === null || typeof item.conversationId === 'string') + || typeof item.work !== 'boolean') return; + return item as CodingLoginRecovery; +} diff --git a/src/lib/coding-teacher.ts b/src/lib/coding-teacher.ts index 36e467e3..fc786010 100644 --- a/src/lib/coding-teacher.ts +++ b/src/lib/coding-teacher.ts @@ -2,6 +2,8 @@ import { hostApiFetch, createHostEventSource, ensureHostApiToken } from './host- import type { ConsultationRole, TeacherAvailability, + TeacherCheckInInput, + TeacherCheckInResult, TeacherDefinition, TeacherSend, TeacherTopic, @@ -14,6 +16,11 @@ export function teacherTopicsPath(projectId: string, sourceId: string, role?: Co : `/api/coding/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(sourceId)}/teacher-topics`; } export const teacherApi = { + checkIn: (projectId: string, input: TeacherCheckInInput) => + hostApiFetch(`/api/coding/projects/${encodeURIComponent(projectId)}/teacher-check-in`, { + method: 'POST', + body: JSON.stringify(input), + }), config: (role: ConsultationRole = 'teacher') => hostApiFetch( `/api/coding/${role}/config` @@ -28,8 +35,8 @@ export const teacherApi = { method: 'POST', body: JSON.stringify({ draftRevision, sampleContext }), }), - read: (base: string, id: string) => - hostApiFetch(base + '/' + encodeURIComponent(id)), + read: (base: string, id: string, select = true) => + hostApiFetch(base + '/' + encodeURIComponent(id) + (select ? '' : '?select=false')), send: (base: string, id: string, input: TeacherSend) => hostApiFetch(base + '/' + encodeURIComponent(id) + '/messages', { method: 'POST', diff --git a/src/lib/coding-work-preview.ts b/src/lib/coding-work-preview.ts new file mode 100644 index 00000000..da5ed50c --- /dev/null +++ b/src/lib/coding-work-preview.ts @@ -0,0 +1,9 @@ +import { hostApiFetch } from './host-api'; +import type { WorkPreviewResult } from '../../shared/coding-work-preview'; + +export function ensureWorkPreview(projectId: string, requestId: string, conversationId?: string | null): Promise { + return hostApiFetch('/api/agent-browser/ensure-work', { + method: 'POST', + body: JSON.stringify({ project_id: projectId, request_id: requestId, conversation_id: conversationId }), + }); +} diff --git a/src/pages/Chat/AgentBrowserPanel.tsx b/src/pages/Chat/AgentBrowserPanel.tsx index eae09f87..54b997c1 100644 --- a/src/pages/Chat/AgentBrowserPanel.tsx +++ b/src/pages/Chat/AgentBrowserPanel.tsx @@ -28,6 +28,11 @@ import { } from '@/lib/agent-browser'; import { subscribeHostEvent } from '@/lib/host-events'; import { cn } from '@/lib/utils'; +import type { CodingConversationMetadata } from '@/types/coding-project'; +import { useWorkPreview } from './use-work-preview'; +import { usePreviewClearance } from './use-preview-clearance'; +import { codingRecovery, type CodingRecoveryAction } from '../../../shared/coding-recovery'; +import type { WorkPreviewResult } from '../../../shared/coding-work-preview'; import type { AgentBrowserBounds, AgentBrowserCdpEvent, @@ -41,6 +46,12 @@ export interface AgentBrowserPanelProps { open: boolean; onOpenChange(open: boolean): void; embedded?: boolean; + teacherBubble?: HTMLElement | null; + conversationId?: string | null; + onWorkConversation?(conversation: CodingConversationMetadata): void; + workRevision?: number; + onWorkResult?(result: WorkPreviewResult): void; + onRecovery?(action: CodingRecoveryAction, work: boolean): Promise | void; } const EVENT_METHODS = [ @@ -121,6 +132,12 @@ export function AgentBrowserPanel({ open, onOpenChange, embedded = false, + teacherBubble, + conversationId, + onWorkConversation, + workRevision, + onWorkResult, + onRecovery, }: AgentBrowserPanelProps) { const [snapshot, setSnapshot] = useState(null); const [address, setAddress] = useState(''); @@ -131,23 +148,64 @@ export function AgentBrowserPanel({ const [activeTab, setActiveTab] = useState('console'); const [events, setEvents] = useState([]); const viewportRef = useRef(null); + const panelRef = useRef(null); + usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open); const addressInputRef = useRef(null); const eventCursorRef = useRef(0); + const snapshotGenerationRef = useRef(0); + const presentationRetries = useRef(0); + const [presentationRevision, setPresentationRevision] = useState(0); + const [presentationFailed, setPresentationFailed] = useState(false); const wasOpenRef = useRef(open); const browserActive = browserIsActive(snapshot); const diagnostics = useMemo(() => deriveAgentBrowserDiagnostics(events), [events]); const applySnapshot = useCallback((next: AgentBrowserSnapshot) => { + // A slow foreground state read must not replace a newly restored view. + if (next.generation < snapshotGenerationRef.current) return; + snapshotGenerationRef.current = next.generation; setSnapshot(next); + if (browserIsActive(next)) setError(null); if (next.url && document.activeElement !== addressInputRef.current) { setAddress(next.url); } }, []); + const work = useWorkPreview({ + projectId, conversationId, enabled: embedded && open, revision: workRevision, + onResult: (result) => { + if (result.browser) applySnapshot(result.browser); + if (result.conversation) onWorkConversation?.(result.conversation); + onWorkResult?.(result); + }, + }); + const workPending = embedded && (work.result?.status !== 'ready' || presentationFailed); + const recovery = codingRecovery(work.result?.errorCode, true); + const transportFailure = ['NETWORK', 'TIMEOUT'].includes(work.result?.errorCode ?? ''); + const showBrowser = work.showBrowser; + const restoreWork = work.restore; + + const retryPresentation = useCallback(() => { + presentationRetries.current = 0; + setPresentationFailed(false); + setError(null); + setPresentationRevision((value) => value + 1); + restoreWork(); + }, [restoreWork]); + useEffect(() => { + if (embedded && open && snapshot && !browserActive && !presentationFailed && work.result?.status === 'ready') { + restoreWork(); + } + }, [browserActive, embedded, open, presentationFailed, restoreWork, snapshot, work.result?.status]); + + useEffect(() => { + snapshotGenerationRef.current = 0; setSnapshot(null); setAddress(''); setError(null); + presentationRetries.current = 0; + setPresentationFailed(false); setDiagnosticsOpen(false); setEvents([]); eventCursorRef.current = 0; @@ -258,7 +316,7 @@ export function AgentBrowserPanel({ useEffect(() => { if (!projectId || !browserActive) return undefined; - if (!open || occluded) { + if (!open || occluded || workPending) { void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined); return undefined; } @@ -278,11 +336,22 @@ export function AgentBrowserPanel({ lastBounds = key; void presentAgentBrowser({ projectId, visible: true, bounds }) .then((next) => { - if (!cancelled) applySnapshot(next); + if (!cancelled) { + applySnapshot(next); + if (browserIsActive(next) && next.visible) presentationRetries.current = 0; + } }) .catch((cause) => { if (!cancelled) { - setError(cause instanceof Error ? cause.message : '无法显示开发浏览器'); + if (embedded) { + if (presentationRetries.current++ < 2) { + setSnapshot(null); + setPresentationRevision((value) => value + 1); + restoreWork(); + } else { + setPresentationFailed(true); + } + } else setError(cause instanceof Error ? cause.message : '无法显示开发浏览器'); } }); }, 50); @@ -300,10 +369,10 @@ export function AgentBrowserPanel({ window.removeEventListener('resize', schedule); void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined); }; - }, [applySnapshot, browserActive, occluded, open, projectId, snapshot?.generation]); + }, [applySnapshot, browserActive, embedded, occluded, open, presentationRevision, projectId, restoreWork, snapshot?.generation, workPending]); useEffect(() => { - if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') { + if (embedded || !open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') { return undefined; } let cancelled = false; @@ -316,10 +385,10 @@ export function AgentBrowserPanel({ cancelled = true; void setAgentBrowserDiagnostics({ projectId, enabled: false }).catch(() => undefined); }; - }, [applySnapshot, diagnosticsOpen, open, projectId, snapshot?.browserId, snapshot?.generation, snapshot?.state]); + }, [applySnapshot, diagnosticsOpen, embedded, open, projectId, snapshot?.browserId, snapshot?.generation, snapshot?.state]); useEffect(() => { - if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') { + if (embedded || !open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') { return undefined; } let cancelled = false; @@ -363,7 +432,7 @@ export function AgentBrowserPanel({ cancelled = true; if (timer !== null) window.clearTimeout(timer); }; - }, [diagnosticsOpen, open, projectId, snapshot?.generation, snapshot?.state]); + }, [diagnosticsOpen, embedded, open, projectId, snapshot?.generation, snapshot?.state]); const runBrowserAction = useCallback(async ( action: () => Promise, @@ -372,12 +441,19 @@ export function AgentBrowserPanel({ setError(null); try { applySnapshot(await action()); + presentationRetries.current = 0; + setPresentationFailed(false); + showBrowser(); } catch (cause) { - setError(cause instanceof Error ? cause.message : '开发浏览器操作失败'); + if (embedded) { + setSnapshot(null); + setPresentationRevision((value) => value + 1); + restoreWork(); + } else setError(cause instanceof Error ? cause.message : '开发浏览器操作失败'); } finally { setBusy(false); } - }, [applySnapshot]); + }, [applySnapshot, embedded, restoreWork, showBrowser]); const handleOpenAddress = useCallback((event?: FormEvent) => { event?.preventDefault(); @@ -399,6 +475,7 @@ export function AgentBrowserPanel({ return (
-
) : null} -
+ } ); } diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index c186542a..33ed748e 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -51,13 +51,21 @@ import { createLocalConversationSnapshot } from './coding-chat-snapshot'; import { useAuthStore } from '@/stores/auth'; import type { ConsultationRole } from '../../../shared/coding-teacher'; import { TeacherChatPanel } from './TeacherChatPanel'; +import { TeacherCompanion } from './TeacherCompanion'; +import { useTeacherCompanion } from './use-teacher-companion'; import type { TeacherReference } from '../../../shared/coding-teacher'; +import { codingRecovery, CONTINUE_CODING_PROMPT, type CodingRecoveryAction } from '../../../shared/coding-recovery'; +import { codingRecoveryAccount, type CodingLoginRecovery } from '@/lib/coding-login-recovery'; +import { openWorksBillingAccount } from '@/lib/works-billing'; export interface CodingChatPanelProps { navigationDraft?: string; onCreateProject?(): void; onOpenProject?(projectId: string): void; onOpenProjectSettings?(): void; + onLoginRecovery?(projectId: string, conversationId: string | null, work: boolean): void; + loginRecovery?: CodingLoginRecovery; + onLoginRecoveryConsumed?(): void; } interface LocalComposerAttachment { @@ -135,6 +143,9 @@ export function CodingChatPanel({ onCreateProject, onOpenProject, onOpenProjectSettings, + onLoginRecovery, + loginRecovery, + onLoginRecoveryConsumed, }: CodingChatPanelProps) { const projects = useCodingWorkspaceStore((state) => state.projects); const activeProject = useCodingWorkspaceStore((state) => state.activeProject); @@ -170,8 +181,13 @@ export function CodingChatPanel({ Record >({}); const [agentBrowserOpen, setAgentBrowserOpen] = useState(false); + const [workRevision, setWorkRevision] = useState(0); + const [readyWorkProjectId, setReadyWorkProjectId] = useState(null); + const recoveryFlight = useRef(false); + const appliedRecovery = useRef(null); const [consultationRole, setConsultationRole] = useState(null); - const [consultationWidth, setConsultationWidth] = useState(380); + const [teacherComposing, setTeacherComposing] = useState(false); + const [consultationWidth, setConsultationWidth] = useState(508); const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); const [teacherQuote, setTeacherQuote] = useState(); const [teacherQuoteSource, setTeacherQuoteSource] = useState(null); @@ -228,6 +244,15 @@ export function CodingChatPanel({ const snapshot = useCodingConversationStore(selectSnapshot); const runStatus = snapshot?.run.status ?? 'idle'; const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); + const teacherCompanion = useTeacherCompanion({ projectId: activeProject?.id ?? null, sourceId: targetConversationId, sourceBusy: running, sourceArchived: Boolean(selectedConversation?.archivedAt), teacherOpen: consultationRole === 'teacher', teacherComposing }); + const [teacherBubble, setTeacherBubble] = useState(null); + const openTeacher = () => { + const selectedText = window.getSelection()?.toString().trim(); + setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined); + setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`); + setConsultationRole('teacher'); + setAgentBrowserOpen(true); + }; const promptMode = running && draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt'; const entryLoadState = useCodingConversationStore((state) => ( targetConversationId @@ -592,6 +617,50 @@ export function CodingChatPanel({ && uncertainRequestCount === 0 && pendingInteractionCount === 0, ); + const handleRecovery = useCallback(async (action: CodingRecoveryAction, work: boolean) => { + if (!activeProject || recoveryFlight.current) return; + if (action === 'login') { onLoginRecovery?.(activeProject.id, targetConversationId, work); return; } + if (action === 'settings') { onOpenProjectSettings?.(); return; } + if (action === 'account') { await openWorksBillingAccount(); return; } + if (action === 'check-work') { setWorkRevision((value) => value + 1); setAgentBrowserOpen(true); return; } + if (!targetConversationId) return; + recoveryFlight.current = true; + try { + if (action === 'refresh') { await loadConversationSnapshot(targetConversationId, 'silent'); return; } + // Refresh before continuing: another window/run may already have made progress. + await loadConversationSnapshot(targetConversationId, 'silent'); + const current = selectCodingConversationSnapshot(targetConversationId)(codingConversationStore.getState()); + if (!current || !['idle', 'error'].includes(current.run.status)) return; + if (current.run.error?.code === 'CODING_REQUEST_UNCERTAIN') return; + const requests = codingConversationStore.getState().requestsByConversationId[targetConversationId]; + if (Object.values(requests ?? {}).some((request) => ['pending', 'uncertain'].includes(request.status))) return; + if (current.run.terminalReason === 'completed') return; + const recoveryAccount = codingRecoveryAccount(useAuthStore.getState().user); + await recoverConversation(targetConversationId); + if (codingWorkspaceStore.getState().activeProjectId !== activeProject.id + || recoveryAccount !== codingRecoveryAccount(useAuthStore.getState().user)) return; + const refreshed = selectCodingConversationSnapshot(targetConversationId)(codingConversationStore.getState()); + if (!refreshed || !['idle', 'error'].includes(refreshed.run.status)) return; + await submitPrompt({ conversationId: targetConversationId, mode: 'prompt', text: CONTINUE_CODING_PROMPT, attachments: [], preserveDraft: true }); + } finally { recoveryFlight.current = false; } + }, [activeProject, targetConversationId, onLoginRecovery, onOpenProjectSettings, loadConversationSnapshot, recoverConversation, submitPrompt]); + + useEffect(() => { + if (!loginRecovery || appliedRecovery.current === loginRecovery.id || workspaceLoadState !== 'ready') return; + if (loginRecovery.account !== codingRecoveryAccount(useAuthStore.getState().user) + || loginRecovery.projectId !== activeProject?.id) { + appliedRecovery.current = loginRecovery.id; + onLoginRecoveryConsumed?.(); + return; + } + if (loginRecovery.conversationId !== targetConversationId + || (targetConversationId ? entryLoadState !== 'live' : workspaceLoadState !== 'ready')) return; + appliedRecovery.current = loginRecovery.id; + onLoginRecoveryConsumed?.(); + void handleRecovery(loginRecovery.work ? 'check-work' : 'continue', loginRecovery.work).catch((error) => { + if (draftKey) setSubmissionErrors((current) => ({ ...current, [draftKey]: localSubmissionError(error) })); + }); + }, [loginRecovery, activeProject, targetConversationId, entryLoadState, workspaceLoadState, handleRecovery, draftKey, onLoginRecoveryConsumed]); const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]); if (!activeProject && workspaceLoadState !== 'loading') { @@ -685,20 +754,15 @@ export function CodingChatPanel({ if (targetConversationId) await abortConversation(targetConversationId); }} onRecover={async () => { - if (targetConversationId) await recoverConversation(targetConversationId); + if (targetConversationId) await handleRecovery(codingRecovery(runtimeError?.code).action, false); }} browserOpen={agentBrowserOpen} browserAvailable={Boolean(activeProject)} onToggleBrowser={() => setAgentBrowserOpen((current) => !current)} projectName={activeProject?.name} consultationRole={consultationRole} + teacherEntry={activeProject ? : undefined} onAskFriend={() => { setTeacherQuote(undefined); setConsultationRole((current) => current === 'friend' ? null : 'friend'); }} - onAskTeacher={() => { - const selectedText = window.getSelection()?.toString().trim(); - setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined); - setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`); - setConsultationRole((current) => current === 'teacher' ? null : 'teacher'); - }} />
@@ -706,7 +770,28 @@ export function CodingChatPanel({
+