diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c4724663db..e0520f8688 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -222,7 +222,6 @@ "src/renderer/use-app-shell-session-workspace.ts", "src/renderer/use-composer-attachments.ts", "src/renderer/use-deep-research-run.ts", - "src/renderer/use-delayed-flag.ts", "src/renderer/use-external-store-selector.ts", "src/renderer/use-new-task-choice.ts", "src/renderer/use-onboarding-snapshot.ts", @@ -4167,25 +4166,6 @@ "react": 1 } }, - "src/renderer/use-delayed-flag.ts": { - "bridgePaths": {}, - "environmentCapabilities": { - "window.clearTimeout": 1, - "window.setTimeout": 1 - }, - "hookCalls": { - "useEffect": 2, - "useRef": 1, - "useState": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./model-wait-state.js": 1, - "react": 1 - } - }, "src/renderer/use-external-store-selector.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4352,7 +4332,7 @@ "actionFactories": [], "dependencyPaths": { "./model-wait-state.js": 1, - "./use-delayed-flag.js": 1 + "@maka/ui": 1 } }, "src/renderer/use-shell-memory-pill.ts": { diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index d49fafb4a0..b31d47d68c 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -26,8 +26,10 @@ import { ClientCapabilityPrompt, finalAssistantReplyText, FormInteractionPrompt, + RUNNING_STATUS_DELAY_MS, SandboxBoundaryPrompt, UserQuestionPrompt, + useDelayedFlag, useToast, useUiLocale, type ChatModelChoice, @@ -56,28 +58,6 @@ import type { CompanionForkVisibilityEvent } from './quote-companion-visibility' import { readScrollMotionBehavior } from '../../../../scroll-motion-policy'; import { useWorkbarServices } from '../../services-context.js'; -const RUNNING_STATUS_DELAY_MS = 200; - -/** - * A boolean that turns true only after `condition` has held for `delayMs`, and - * false the moment it drops — the rising-edge delay that keeps a fast turn from - * flashing the running-status line. A feature-local copy of the shell's - * useDelayedFlag: the renderer-legacy original is walled off from feature code - * by the architecture budget, and this is only a few lines of timer plumbing. - */ -function useDelayedFlag(condition: boolean, delayMs: number): boolean { - const [visible, setVisible] = useState(false); - useEffect(() => { - if (!condition) { - setVisible(false); - return; - } - const handle = window.setTimeout(() => setVisible(true), delayMs); - return () => window.clearTimeout(handle); - }, [condition, delayMs]); - return visible; -} - /** * The side-conversation workbar tab: a transient read-only fork of the main session. * It renders with the SAME surface as the main conversation — the real diff --git a/apps/desktop/src/renderer/model-wait-state.ts b/apps/desktop/src/renderer/model-wait-state.ts index ea8b51b600..941234f16f 100644 --- a/apps/desktop/src/renderer/model-wait-state.ts +++ b/apps/desktop/src/renderer/model-wait-state.ts @@ -18,8 +18,7 @@ */ /** - * Pure model-wait derivation + rising-edge debounce for the two turn-wait cues - * (#646). + * Pure model-wait derivation for the two turn-wait cues (#646). * * A turn has two kinds of "nothing is streaming right now" lulls, and they must * read differently: @@ -34,21 +33,16 @@ * this split fixes). * * The single dimension that separates them is the turn PHASE: `'waiting'` until - * the first content event, `'streamed'` after. Kept free of React so the timing - * is unit-tested with an injected scheduler (fake timers). + * the first content event, `'streamed'` after. Kept free of React so the + * derivation is unit-tested directly. The rising-edge debounce that reveals + * these cues (`createDelayedFlag` / `useDelayedFlag`) and the running-status + * line's `RUNNING_STATUS_DELAY_MS` live in `@maka/ui` so the side-conversation + * feature can share them. */ /** Rising-edge delay before the first-token processing indicator appears. Tunable. */ export const MODEL_PROCESSING_DELAY_MS = 200; -/** - * Rising-edge delay before the transcript's running status line appears. - * - * Same no-flash rule as the cue above, but keyed off the turn being active - * rather than off a lull, because that line stays up for the whole turn. - */ -export const RUNNING_STATUS_DELAY_MS = 200; - /** * Rising-edge delay before the mid-turn "继续中…" hint appears. Longer than the * first-token delay so a quick hop between two fast steps never flashes it — the @@ -117,74 +111,3 @@ export function deriveModelWait(input: ModelWaitInputs): ModelWaitKind { if (!idle || input.turnPhase === undefined) return 'none'; return input.turnPhase === 'waiting' ? 'processing' : 'continuing'; } - -export interface DelayedFlagScheduler { - setTimeout(handler: () => void, ms: number): unknown; - clearTimeout(handle: unknown): void; -} - -export interface DelayedFlag { - /** Feed the current condition; drives the flag through the delay. */ - setCondition(active: boolean): void; - /** Current visible flag. */ - get(): boolean; - /** Cancel any pending timer (unmount / teardown). */ - dispose(): void; -} - -/** - * A rising-edge–delayed boolean. The flag turns true only after the condition - * stays true for `delayMs`; if the condition drops before the delay elapses the - * flag never turns true (the fast-response no-flash rule). Falling to false is - * immediate. The scheduler is injected so the timing is testable with fake - * timers instead of a real 200ms wall-clock wait. - */ -export function createDelayedFlag(opts: { - delayMs: number; - scheduler: DelayedFlagScheduler; - onChange?: (visible: boolean) => void; -}): DelayedFlag { - const { delayMs, scheduler, onChange } = opts; - let condition = false; - let visible = false; - let timer: unknown = null; - - function clearTimer(): void { - if (timer !== null) { - scheduler.clearTimeout(timer); - timer = null; - } - } - - function emit(next: boolean): void { - if (next === visible) return; - visible = next; - onChange?.(visible); - } - - return { - setCondition(active: boolean): void { - if (active === condition) return; - condition = active; - if (active) { - // Rising edge: arm once. Already-visible (re-entrant true) keeps state. - if (!visible && timer === null) { - timer = scheduler.setTimeout(() => { - timer = null; - emit(true); - }, delayMs); - } - } else { - // Falling edge: cancel a pending reveal and hide immediately. - clearTimer(); - emit(false); - } - }, - get(): boolean { - return visible; - }, - dispose(): void { - clearTimer(); - }, - }; -} diff --git a/apps/desktop/src/renderer/use-delayed-flag.ts b/apps/desktop/src/renderer/use-delayed-flag.ts deleted file mode 100644 index 889c0f2c40..0000000000 --- a/apps/desktop/src/renderer/use-delayed-flag.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { useEffect, useRef, useState } from 'react'; -import { createDelayedFlag, type DelayedFlag } from './model-wait-state.js'; - -/** - * React binding for `createDelayedFlag` (#646): a boolean that turns true only - * after `condition` has held true for `delayMs`, and false immediately when it - * drops. The timing/arm/cancel logic lives in the pure `createDelayedFlag` (unit - * tested with fake timers); this hook only wires it to `window` timers + a - * re-render. `delayMs` is read once at mount — it is a constant in practice. - */ -export function useDelayedFlag(condition: boolean, delayMs: number): boolean { - const [visible, setVisible] = useState(false); - const flagRef = useRef(null); - if (flagRef.current === null) { - flagRef.current = createDelayedFlag({ - delayMs, - scheduler: { - setTimeout: (handler, ms) => window.setTimeout(handler, ms), - clearTimeout: (handle) => window.clearTimeout(handle as number), - }, - onChange: setVisible, - }); - } - useEffect(() => { - flagRef.current?.setCondition(condition); - }, [condition]); - useEffect(() => () => flagRef.current?.dispose(), []); - return visible; -} diff --git a/apps/desktop/src/renderer/use-shell-live-turn.ts b/apps/desktop/src/renderer/use-shell-live-turn.ts index ab85769bf9..5fb116dce7 100644 --- a/apps/desktop/src/renderer/use-shell-live-turn.ts +++ b/apps/desktop/src/renderer/use-shell-live-turn.ts @@ -18,9 +18,9 @@ */ import type { SessionSummary } from '@maka/core/session'; +import { RUNNING_STATUS_DELAY_MS, useDelayedFlag } from '@maka/ui'; import type { LiveTurnSnapshot } from './live-turn-snapshot.js'; -import { MODEL_CONTINUING_DELAY_MS, MODEL_PROCESSING_DELAY_MS, RUNNING_STATUS_DELAY_MS, deriveModelWait, deriveTurnActive, type ModelWaitKind } from './model-wait-state.js'; -import { useDelayedFlag } from './use-delayed-flag.js'; +import { MODEL_CONTINUING_DELAY_MS, MODEL_PROCESSING_DELAY_MS, deriveModelWait, deriveTurnActive, type ModelWaitKind } from './model-wait-state.js'; /** * Owns everything the SHELL derives from the active session's live turn: the diff --git a/packages/ui/src/__tests__/delayed-flag.test.ts b/packages/ui/src/__tests__/delayed-flag.test.ts new file mode 100644 index 0000000000..abd442cfc8 --- /dev/null +++ b/packages/ui/src/__tests__/delayed-flag.test.ts @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createDelayedFlag, type DelayedFlagScheduler } from '../delayed-flag.js'; + +// A manual scheduler: nothing fires until the test explicitly releases it, so +// the rising-edge timing is asserted without any real wall-clock wait. +function fakeScheduler() { + let seq = 0; + const pending = new Map void>(); + const scheduler: DelayedFlagScheduler = { + setTimeout(handler) { + const id = ++seq; + pending.set(id, handler); + return id; + }, + clearTimeout(handle) { + pending.delete(handle as number); + }, + }; + return { + scheduler, + pendingCount: () => pending.size, + fireAll() { + for (const [id, handler] of [...pending]) { + pending.delete(id); + handler(); + } + }, + }; +} + +test('stays false until the delay elapses, then turns true once', () => { + const changes: boolean[] = []; + const s = fakeScheduler(); + const flag = createDelayedFlag({ + delayMs: 200, + scheduler: s.scheduler, + onChange: (v) => changes.push(v), + }); + + flag.setCondition(true); + assert.equal(flag.get(), false, 'not visible before the timer fires'); + assert.deepEqual(changes, []); + assert.equal(s.pendingCount(), 1, 'one reveal timer armed'); + + s.fireAll(); + assert.equal(flag.get(), true); + assert.deepEqual(changes, [true]); +}); + +test('a condition that drops before the delay never flashes true', () => { + const changes: boolean[] = []; + const s = fakeScheduler(); + const flag = createDelayedFlag({ + delayMs: 200, + scheduler: s.scheduler, + onChange: (v) => changes.push(v), + }); + + flag.setCondition(true); + flag.setCondition(false); + assert.equal(s.pendingCount(), 0, 'the pending reveal is cancelled on the fast drop'); + + s.fireAll(); + assert.equal(flag.get(), false); + assert.deepEqual(changes, [], 'never emitted — no flash'); +}); + +test('falls to false immediately once visible, with no timer', () => { + const changes: boolean[] = []; + const s = fakeScheduler(); + const flag = createDelayedFlag({ + delayMs: 200, + scheduler: s.scheduler, + onChange: (v) => changes.push(v), + }); + + flag.setCondition(true); + s.fireAll(); + assert.equal(flag.get(), true); + + flag.setCondition(false); + assert.equal(flag.get(), false, 'falling edge is immediate'); + assert.equal(s.pendingCount(), 0); + assert.deepEqual(changes, [true, false]); +}); + +test('a re-entrant true while pending arms only one timer', () => { + const s = fakeScheduler(); + const flag = createDelayedFlag({ delayMs: 200, scheduler: s.scheduler }); + + flag.setCondition(true); + flag.setCondition(true); + assert.equal(s.pendingCount(), 1); +}); + +test('dispose cancels a pending reveal', () => { + const changes: boolean[] = []; + const s = fakeScheduler(); + const flag = createDelayedFlag({ + delayMs: 200, + scheduler: s.scheduler, + onChange: (v) => changes.push(v), + }); + + flag.setCondition(true); + flag.dispose(); + assert.equal(s.pendingCount(), 0); + + s.fireAll(); + assert.equal(flag.get(), false); + assert.deepEqual(changes, []); +}); diff --git a/packages/ui/src/delayed-flag.ts b/packages/ui/src/delayed-flag.ts new file mode 100644 index 0000000000..501a2a7b5b --- /dev/null +++ b/packages/ui/src/delayed-flag.ts @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useRef, useState } from 'react'; + +/** + * Rising-edge delay before the transcript's running-status line appears (#646). + * The line stays up for the whole active turn, so the flag is keyed off the turn + * being active; the delay keeps a fast turn from flashing it. Lives here — a + * feature-importable package — so the main conversation (`use-shell-live-turn`) + * and the side-conversation panel share ONE delay and ONE `useDelayedFlag` + * rather than the renderer-legacy original the architecture gate walls features + * off from (which forced a drifting feature-local copy). + */ +export const RUNNING_STATUS_DELAY_MS = 200; + +export interface DelayedFlagScheduler { + setTimeout(handler: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +} + +export interface DelayedFlag { + /** Feed the current condition; drives the flag through the delay. */ + setCondition(active: boolean): void; + /** Current visible flag. */ + get(): boolean; + /** Cancel any pending timer (unmount / teardown). */ + dispose(): void; +} + +/** + * A rising-edge–delayed boolean. The flag turns true only after the condition + * stays true for `delayMs`; if the condition drops before the delay elapses the + * flag never turns true (the fast-response no-flash rule). Falling to false is + * immediate. The scheduler is injected so the timing is testable with fake + * timers instead of a real 200ms wall-clock wait. + */ +export function createDelayedFlag(opts: { + delayMs: number; + scheduler: DelayedFlagScheduler; + onChange?: (visible: boolean) => void; +}): DelayedFlag { + const { delayMs, scheduler, onChange } = opts; + let condition = false; + let visible = false; + let timer: unknown = null; + + function clearTimer(): void { + if (timer !== null) { + scheduler.clearTimeout(timer); + timer = null; + } + } + + function emit(next: boolean): void { + if (next === visible) return; + visible = next; + onChange?.(visible); + } + + return { + setCondition(active: boolean): void { + if (active === condition) return; + condition = active; + if (active) { + // Rising edge: arm once. Already-visible (re-entrant true) keeps state. + if (!visible && timer === null) { + timer = scheduler.setTimeout(() => { + timer = null; + emit(true); + }, delayMs); + } + } else { + // Falling edge: cancel a pending reveal and hide immediately. + clearTimer(); + emit(false); + } + }, + get(): boolean { + return visible; + }, + dispose(): void { + clearTimer(); + }, + }; +} + +/** + * React binding for `createDelayedFlag` (#646): a boolean that turns true only + * after `condition` has held true for `delayMs`, and false immediately when it + * drops. The timing/arm/cancel logic lives in the pure `createDelayedFlag` (unit + * tested with fake timers); this hook only wires it to `window` timers + a + * re-render. `delayMs` is read once at mount — it is a constant in practice. + */ +export function useDelayedFlag(condition: boolean, delayMs: number): boolean { + const [visible, setVisible] = useState(false); + const flagRef = useRef(null); + if (flagRef.current === null) { + flagRef.current = createDelayedFlag({ + delayMs, + scheduler: { + setTimeout: (handler, ms) => window.setTimeout(handler, ms), + clearTimeout: (handle) => window.clearTimeout(handle as number), + }, + onChange: setVisible, + }); + } + useEffect(() => { + flagRef.current?.setCondition(condition); + }, [condition]); + useEffect(() => () => flagRef.current?.dispose(), []); + return visible; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 267a4c1fff..e4e2b23b25 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -21,6 +21,7 @@ export * from './artifact-preview-registry.js'; export * from './assistant-stream.js'; export * from './chat-empty-hero.js'; export * from './chat-model-helpers.js'; +export * from './delayed-flag.js'; export * from './use-mounted-ref.js'; export * from './session-setting-intent.js'; export * from './components.js';