Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 1 addition & 21 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {},
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import {
ClientCapabilityPrompt,
finalAssistantReplyText,
FormInteractionPrompt,
RUNNING_STATUS_DELAY_MS,
SandboxBoundaryPrompt,
UserQuestionPrompt,
useDelayedFlag,
useToast,
useUiLocale,
type ChatModelChoice,
Expand Down Expand Up @@ -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
Expand Down
89 changes: 6 additions & 83 deletions apps/desktop/src/renderer/model-wait-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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();
},
};
}
48 changes: 0 additions & 48 deletions apps/desktop/src/renderer/use-delayed-flag.ts

This file was deleted.

4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/use-shell-live-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions packages/ui/src/__tests__/delayed-flag.test.ts
Original file line number Diff line number Diff line change
@@ -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<number, () => 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, []);
});
Loading
Loading