mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
#367 (UserPromptSubmit hook): `hook:prompt_submitted` went on the wire unregistered; it is now in both SSE registries (158 = 158), and the hook only lands in the run summary when the conversation actually moved, since one row per prompt would evict useful rows from the 1000-event FIFO and clutter the Summary timeline and /api/search. #368 (Add Case header submit): the pending-state dimming targeted the footer button, which the <=860px layout hides, so on a phone the only visible submit control stayed at full brightness while a clone ran. The header button now dims too, and a static test pins the header-submit contract so it cannot silently disappear again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -958,6 +958,7 @@ const SSE_EVENTS = {
|
|||||||
HOOK_AGENT_WORKING: 'hook:agent_working',
|
HOOK_AGENT_WORKING: 'hook:agent_working',
|
||||||
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
||||||
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
||||||
|
HOOK_PROMPT_SUBMITTED: 'hook:prompt_submitted',
|
||||||
|
|
||||||
// Approvals Inbox
|
// Approvals Inbox
|
||||||
APPROVAL_PENDING: 'approval:pending',
|
APPROVAL_PENDING: 'approval:pending',
|
||||||
|
|||||||
@@ -3355,6 +3355,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Add Case's pending state has to show on the header button: below 860px it is
|
||||||
|
the only submit control (the footer is hidden), and the #caseModalSubmit
|
||||||
|
.loading rule in the phone block dims a button nobody can see. Measured at
|
||||||
|
390px before this: header opacity 1 for the whole clone, hidden footer 0.6. */
|
||||||
|
#createCaseModal .set-head-save.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close {
|
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export function registerHookEventRoutes(
|
|||||||
// Sync Claude's current conversation id. Interactive PTY mode never emits
|
// Sync Claude's current conversation id. Interactive PTY mode never emits
|
||||||
// `session_id` on stdout, so hooks are the only reliable way to learn that
|
// `session_id` on stdout, so hooks are the only reliable way to learn that
|
||||||
// the user ran `/clear` (which spins up a new conversation jsonl).
|
// the user ran `/clear` (which spins up a new conversation jsonl).
|
||||||
|
let conversationChanged = false;
|
||||||
if (data && typeof data.session_id === 'string' && data.session_id) {
|
if (data && typeof data.session_id === 'string' && data.session_id) {
|
||||||
const session = ctx.sessions.get(sessionId);
|
const session = ctx.sessions.get(sessionId);
|
||||||
const prevClaudeSessionId = session?.claudeSessionId;
|
const prevClaudeSessionId = session?.claudeSessionId;
|
||||||
@@ -150,6 +151,7 @@ export function registerHookEventRoutes(
|
|||||||
session &&
|
session &&
|
||||||
(session.claudeSessionId !== prevClaudeSessionId || session.claudeSessionChain.length !== prevChainLength)
|
(session.claudeSessionId !== prevClaudeSessionId || session.claudeSessionChain.length !== prevChainLength)
|
||||||
) {
|
) {
|
||||||
|
conversationChanged = true;
|
||||||
ctx.persistSessionState(session);
|
ctx.persistSessionState(session);
|
||||||
}
|
}
|
||||||
// Docker sessions: keep the case's resume seed following the LIVE
|
// Docker sessions: keep the case's resume seed following the LIVE
|
||||||
@@ -229,9 +231,13 @@ export function registerHookEventRoutes(
|
|||||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track in run summary
|
// Track in run summary. `prompt_submitted` fires on EVERY prompt of every
|
||||||
|
// Claude pane; only the ones where the conversation actually moved (a /clear
|
||||||
|
// successor) carry information, and recording the rest would push a row into
|
||||||
|
// the Summary timeline and /api/search per turn and evict useful rows from
|
||||||
|
// the 1000-event FIFO (#367 merge-time fix).
|
||||||
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
||||||
if (summaryTracker) {
|
if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) {
|
||||||
summaryTracker.recordHookEvent(event, safeData);
|
summaryTracker.recordHookEvent(event, safeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||||
* Both files MUST be kept in sync.
|
* Both files MUST be kept in sync.
|
||||||
*
|
*
|
||||||
* 157 event constants organized by category:
|
* 158 event constants organized by category:
|
||||||
* - **Core** (1): init
|
* - **Core** (1): init
|
||||||
* - **Transport** (1): sse:heartbeat
|
* - **Transport** (1): sse:heartbeat
|
||||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
||||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||||
* - **Image / attachments** (2): image:detected, attachment:detected
|
* - **Image / attachments** (2): image:detected, attachment:detected
|
||||||
* - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed
|
* - **Hooks** (10): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed, prompt_submitted
|
||||||
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
|
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
|
||||||
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
|
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
|
||||||
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
||||||
@@ -372,6 +372,8 @@ export const HookAgentWorking = 'hook:agent_working' as const;
|
|||||||
export const HookTeammateIdle = 'hook:teammate_idle' as const;
|
export const HookTeammateIdle = 'hook:teammate_idle' as const;
|
||||||
/** Claude Code hook: teammate task completed. */
|
/** Claude Code hook: teammate task completed. */
|
||||||
export const HookTaskCompleted = 'hook:task_completed' as const;
|
export const HookTaskCompleted = 'hook:task_completed' as const;
|
||||||
|
/** UserPromptSubmit fired in a Claude pane (#367): the pane learned its live conversation id first-hand. */
|
||||||
|
export const HookPromptSubmitted = 'hook:prompt_submitted' as const;
|
||||||
|
|
||||||
// ─── Approvals Inbox ─────────────────────────────────────────────────────────
|
// ─── Approvals Inbox ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Static guard for the Add Case modal's submit controls (#368).
|
||||||
|
*
|
||||||
|
* Below 860px the shared set-* surface hides the modal footer, and for eight
|
||||||
|
* releases that footer held the only Create/Clone/Link button, so no case could
|
||||||
|
* be added from a phone and nothing failed. This pins the contract that fixed it:
|
||||||
|
* a header submit button exists after the close button, and the two JS paths
|
||||||
|
* that toggle submit state drive BOTH buttons.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||||
|
const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8');
|
||||||
|
const mobileCss = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8');
|
||||||
|
|
||||||
|
function caseModal(): string {
|
||||||
|
const start = html.indexOf('<div class="modal" id="createCaseModal">');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const next = html.indexOf('<div class="modal"', start + 1);
|
||||||
|
return html.slice(start, next === -1 ? html.length : next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodBody(signature: string): string {
|
||||||
|
const start = sessionUi.indexOf(`\n ${signature} {`);
|
||||||
|
expect(start, `${signature} not found in session-ui.js`).toBeGreaterThan(-1);
|
||||||
|
return sessionUi.slice(start, sessionUi.indexOf('\n },', start));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Add Case modal submit controls', () => {
|
||||||
|
it('hides the footer on phones, so the header must carry a submit button', () => {
|
||||||
|
expect(mobileCss).toMatch(
|
||||||
|
/:is\(#appSettingsModal, #sessionOptionsModal, #createCaseModal\) \.set-foot \{\s*display: none;/
|
||||||
|
);
|
||||||
|
const modal = caseModal();
|
||||||
|
const head = modal.slice(0, modal.indexOf('<div class="set-body">'));
|
||||||
|
const closeIdx = head.indexOf('class="modal-close"');
|
||||||
|
const saveIdx = head.indexOf('class="set-head-save" id="caseModalSubmitMobile" onclick="app.submitCaseModal()"');
|
||||||
|
expect(closeIdx).toBeGreaterThan(-1);
|
||||||
|
expect(saveIdx).toBeGreaterThan(-1);
|
||||||
|
// Close stays first in the DOM; row-reverse paints Save to its left.
|
||||||
|
expect(closeIdx).toBeLessThan(saveIdx);
|
||||||
|
expect(modal).toContain('id="caseModalSubmit" onclick="app.submitCaseModal()"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drives the footer and header submit buttons together', () => {
|
||||||
|
for (const sig of ['switchCaseModalTab(tabName)', 'async submitCaseModal()']) {
|
||||||
|
const body = methodBody(sig);
|
||||||
|
expect(body, sig).toContain("'caseModalSubmit'");
|
||||||
|
expect(body, sig).toContain("'caseModalSubmitMobile'");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dims the header button while a submit is pending, where it is the only one visible', () => {
|
||||||
|
expect(mobileCss).toMatch(/#createCaseModal \.set-head-save\.loading \{\s*opacity: 0\.6;\s*pointer-events: none;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user