mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-2): 格式化 commands (79 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-5): 格式化 components其余 + hooks + tools (232 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md - README.md: 大幅重写,更详细版本历史和配置示例 - Run.ps1: 新增 Windows 启动脚本 - TODO.md: 新增包完成清单 - V6.md: 删除(架构重构规划已不适用) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复以前的问题 * fix: 修复 login 面板的问题 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,89 +1,95 @@
|
||||
import { c as _c } from 'react/compiler-runtime';
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import { Box, Text } from '../ink.js';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { computeGlimmerIndex, computeShimmerSegments, SHIMMER_INTERVAL_MS } from '../bridge/bridgeStatusUtil.js';
|
||||
import { feature } from 'bun:bundle';
|
||||
import { getKairosActive, getUserMsgOptIn } from '../bootstrap/state.js';
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
|
||||
import { isEnvTruthy } from '../utils/envUtils.js';
|
||||
import { count } from '../utils/array.js';
|
||||
import sample from 'lodash-es/sample.js';
|
||||
import { formatDuration, formatNumber, formatSecondsShort } from '../utils/format.js';
|
||||
import type { Theme } from 'src/utils/theme.js';
|
||||
import { activityManager } from '../utils/activityManager.js';
|
||||
import { getSpinnerVerbs } from '../constants/spinnerVerbs.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
import { TaskListV2 } from './TaskListV2.js';
|
||||
import { useTasksV2 } from '../hooks/useTasksV2.js';
|
||||
import type { Task } from '../utils/tasks.js';
|
||||
import { useAppState } from '../state/AppState.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { stringWidth } from '../ink/stringWidth.js';
|
||||
import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js';
|
||||
import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js';
|
||||
import { useSettings } from '../hooks/useSettings.js';
|
||||
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js';
|
||||
import { isBackgroundTask } from '../tasks/types.js';
|
||||
import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js';
|
||||
import { getEffortSuffix } from '../utils/effort.js';
|
||||
import { getMainLoopModel } from '../utils/model/model.js';
|
||||
import { getViewedTeammateTask } from '../state/selectors.js';
|
||||
import { TEARDROP_ASTERISK } from '../constants/figures.js';
|
||||
import figures from 'figures';
|
||||
import { getCurrentTurnTokenBudget, getTurnOutputTokens } from '../bootstrap/state.js';
|
||||
import { TeammateSpinnerTree } from './Spinner/TeammateSpinnerTree.js';
|
||||
import { useAnimationFrame } from '../ink.js';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
export type { SpinnerMode } from './Spinner/index.js';
|
||||
const DEFAULT_CHARACTERS = getDefaultCharacters();
|
||||
const SPINNER_FRAMES = [...DEFAULT_CHARACTERS, ...[...DEFAULT_CHARACTERS].reverse()];
|
||||
type Props = {
|
||||
mode: SpinnerMode;
|
||||
loadingStartTimeRef: React.RefObject<number>;
|
||||
totalPausedMsRef: React.RefObject<number>;
|
||||
pauseStartTimeRef: React.RefObject<number | null>;
|
||||
spinnerTip?: string;
|
||||
responseLengthRef: React.RefObject<number>;
|
||||
apiMetricsRef?: React.RefObject<
|
||||
Array<{
|
||||
ttftMs: number;
|
||||
firstTokenTime: number;
|
||||
lastTokenTime: number;
|
||||
responseLengthBaseline: number;
|
||||
endResponseLength: number;
|
||||
}>
|
||||
>;
|
||||
overrideColor?: keyof Theme | null;
|
||||
overrideShimmerColor?: keyof Theme | null;
|
||||
overrideMessage?: string | null;
|
||||
spinnerSuffix?: string | null;
|
||||
verbose: boolean;
|
||||
hasActiveTools?: boolean;
|
||||
/** Leader's turn has completed (no active query). Used to suppress stall-red spinner when only teammates are running. */
|
||||
leaderIsIdle?: boolean;
|
||||
};
|
||||
import { Box, Text } from '../ink.js'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
computeGlimmerIndex,
|
||||
computeShimmerSegments,
|
||||
SHIMMER_INTERVAL_MS,
|
||||
} from '../bridge/bridgeStatusUtil.js'
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive, getUserMsgOptIn } from '../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
import { count } from '../utils/array.js'
|
||||
import sample from 'lodash-es/sample.js'
|
||||
import {
|
||||
formatDuration,
|
||||
formatNumber,
|
||||
formatSecondsShort,
|
||||
} from '../utils/format.js'
|
||||
import type { Theme } from 'src/utils/theme.js'
|
||||
import { activityManager } from '../utils/activityManager.js'
|
||||
import { getSpinnerVerbs } from '../constants/spinnerVerbs.js'
|
||||
import { MessageResponse } from './MessageResponse.js'
|
||||
import { TaskListV2 } from './TaskListV2.js'
|
||||
import { useTasksV2 } from '../hooks/useTasksV2.js'
|
||||
import type { Task } from '../utils/tasks.js'
|
||||
import { useAppState } from '../state/AppState.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { stringWidth } from '../ink/stringWidth.js'
|
||||
import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js'
|
||||
import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js'
|
||||
import { useSettings } from '../hooks/useSettings.js'
|
||||
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'
|
||||
import { isBackgroundTask } from '../tasks/types.js'
|
||||
import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
|
||||
import { getEffortSuffix } from '../utils/effort.js'
|
||||
import { getMainLoopModel } from '../utils/model/model.js'
|
||||
import { getViewedTeammateTask } from '../state/selectors.js'
|
||||
import { TEARDROP_ASTERISK } from '../constants/figures.js'
|
||||
import figures from 'figures'
|
||||
import {
|
||||
getCurrentTurnTokenBudget,
|
||||
getTurnOutputTokens,
|
||||
} from '../bootstrap/state.js'
|
||||
|
||||
// Polyfill ant-only global functions that are normally injected by the bundler.
|
||||
const computeTtftText = (metrics: ApiMetricEntry[]): string => '';
|
||||
import { TeammateSpinnerTree } from './Spinner/TeammateSpinnerTree.js'
|
||||
import { useAnimationFrame } from '../ink.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
export type { SpinnerMode } from './Spinner/index.js'
|
||||
|
||||
const DEFAULT_CHARACTERS = getDefaultCharacters()
|
||||
|
||||
const SPINNER_FRAMES = [
|
||||
...DEFAULT_CHARACTERS,
|
||||
...[...DEFAULT_CHARACTERS].reverse(),
|
||||
]
|
||||
|
||||
|
||||
type Props = {
|
||||
mode: SpinnerMode
|
||||
loadingStartTimeRef: React.RefObject<number>
|
||||
totalPausedMsRef: React.RefObject<number>
|
||||
pauseStartTimeRef: React.RefObject<number | null>
|
||||
spinnerTip?: string
|
||||
responseLengthRef: React.RefObject<number>
|
||||
overrideColor?: keyof Theme | null
|
||||
overrideShimmerColor?: keyof Theme | null
|
||||
overrideMessage?: string | null
|
||||
spinnerSuffix?: string | null
|
||||
verbose: boolean
|
||||
hasActiveTools?: boolean
|
||||
/** Leader's turn has completed (no active query). Used to suppress stall-red spinner when only teammates are running. */
|
||||
leaderIsIdle?: boolean
|
||||
}
|
||||
|
||||
// Thin wrapper: branches on isBriefOnly so the two variants have independent
|
||||
// hook call chains. Without this split, toggling /brief mid-render would
|
||||
// violate Rules of Hooks (the inner variant calls ~10 more hooks).
|
||||
export function SpinnerWithVerb(props: Props): React.ReactNode {
|
||||
const isBriefOnly = useAppState(s => s.isBriefOnly);
|
||||
const isBriefOnly = useAppState(s => s.isBriefOnly)
|
||||
// REPL overrides isBriefOnly→false when viewing a teammate transcript
|
||||
// (see isBriefOnly={viewedTeammateTask ? false : isBriefOnly}). That
|
||||
// prop isn't threaded here, so replicate the gate from the store —
|
||||
// teammate view needs the real spinner (which shows teammate status).
|
||||
const viewingAgentTaskId = useAppState(s_0 => s_0.viewingAgentTaskId);
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
||||
// Hoisted to mount-time — this component re-renders at animation framerate.
|
||||
const briefEnvEnabled =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
||||
: false;
|
||||
: false
|
||||
|
||||
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
|
||||
// BriefTool.ts would leak tool-name strings into external builds. Single
|
||||
@@ -91,14 +97,20 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
|
||||
if (
|
||||
(feature('KAIROS') || feature('KAIROS_BRIEF')) &&
|
||||
(getKairosActive() ||
|
||||
(getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
|
||||
(getUserMsgOptIn() &&
|
||||
(briefEnvEnabled ||
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
|
||||
isBriefOnly &&
|
||||
!viewingAgentTaskId
|
||||
) {
|
||||
return <BriefSpinner mode={props.mode} overrideMessage={props.overrideMessage} />;
|
||||
return (
|
||||
<BriefSpinner mode={props.mode} overrideMessage={props.overrideMessage} />
|
||||
)
|
||||
}
|
||||
return <SpinnerWithVerbInner {...props} />;
|
||||
|
||||
return <SpinnerWithVerbInner {...props} />
|
||||
}
|
||||
|
||||
function SpinnerWithVerbInner({
|
||||
mode,
|
||||
loadingStartTimeRef,
|
||||
@@ -106,7 +118,6 @@ function SpinnerWithVerbInner({
|
||||
pauseStartTimeRef,
|
||||
spinnerTip,
|
||||
responseLengthRef,
|
||||
apiMetricsRef,
|
||||
overrideColor,
|
||||
overrideShimmerColor,
|
||||
overrideMessage,
|
||||
@@ -115,8 +126,8 @@ function SpinnerWithVerbInner({
|
||||
hasActiveTools = false,
|
||||
leaderIsIdle = false,
|
||||
}: Props): React.ReactNode {
|
||||
const settings = useSettings();
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false;
|
||||
const settings = useSettings()
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false
|
||||
|
||||
// NOTE: useAnimationFrame(50) lives in SpinnerAnimationRow, not here.
|
||||
// This component only re-renders when props or app state change —
|
||||
@@ -124,100 +135,114 @@ function SpinnerWithVerbInner({
|
||||
// (frame, glimmer, stalled intensity, token counter, thinking shimmer,
|
||||
// elapsed-time timer) are computed inside the child.
|
||||
|
||||
const tasks = useAppState(s => s.tasks);
|
||||
const viewingAgentTaskId = useAppState(s_0 => s_0.viewingAgentTaskId);
|
||||
const expandedView = useAppState(s_1 => s_1.expandedView);
|
||||
const showExpandedTodos = expandedView === 'tasks';
|
||||
const showSpinnerTree = expandedView === 'teammates';
|
||||
const selectedIPAgentIndex = useAppState(s_2 => s_2.selectedIPAgentIndex);
|
||||
const viewSelectionMode = useAppState(s_3 => s_3.viewSelectionMode);
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
||||
const expandedView = useAppState(s => s.expandedView)
|
||||
const showExpandedTodos = expandedView === 'tasks'
|
||||
const showSpinnerTree = expandedView === 'teammates'
|
||||
const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex)
|
||||
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
|
||||
// Get foregrounded teammate (if viewing a teammate's transcript)
|
||||
const foregroundedTeammate = viewingAgentTaskId
|
||||
? getViewedTeammateTask({
|
||||
viewingAgentTaskId,
|
||||
tasks,
|
||||
})
|
||||
: undefined;
|
||||
const { columns } = useTerminalSize();
|
||||
const tasksV2 = useTasksV2();
|
||||
? getViewedTeammateTask({ viewingAgentTaskId, tasks })
|
||||
: undefined
|
||||
const { columns } = useTerminalSize()
|
||||
const tasksV2 = useTasksV2()
|
||||
|
||||
// Track thinking status: 'thinking' | number (duration in ms) | null
|
||||
// Shows each state for minimum 2s to avoid UI jank
|
||||
const [thinkingStatus, setThinkingStatus] = useState<'thinking' | number | null>(null);
|
||||
const thinkingStartRef = useRef<number | null>(null);
|
||||
const [thinkingStatus, setThinkingStatus] = useState<
|
||||
'thinking' | number | null
|
||||
>(null)
|
||||
const thinkingStartRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let showDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let clearStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let showDurationTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let clearStatusTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
if (mode === 'thinking') {
|
||||
// Started thinking
|
||||
if (thinkingStartRef.current === null) {
|
||||
thinkingStartRef.current = Date.now();
|
||||
setThinkingStatus('thinking');
|
||||
thinkingStartRef.current = Date.now()
|
||||
setThinkingStatus('thinking')
|
||||
}
|
||||
} else if (thinkingStartRef.current !== null) {
|
||||
// Stopped thinking - calculate duration and ensure 2s minimum display
|
||||
const duration = Date.now() - thinkingStartRef.current;
|
||||
const elapsed = Date.now() - thinkingStartRef.current;
|
||||
const remainingThinkingTime = Math.max(0, 2000 - elapsed);
|
||||
thinkingStartRef.current = null;
|
||||
const duration = Date.now() - thinkingStartRef.current
|
||||
const elapsed = Date.now() - thinkingStartRef.current
|
||||
const remainingThinkingTime = Math.max(0, 2000 - elapsed)
|
||||
|
||||
thinkingStartRef.current = null
|
||||
|
||||
// Show "thinking..." for remaining time if < 2s elapsed, then show duration
|
||||
const showDuration = (): void => {
|
||||
setThinkingStatus(duration);
|
||||
setThinkingStatus(duration)
|
||||
// Clear after 2s
|
||||
clearStatusTimer = setTimeout(setThinkingStatus, 2000, null);
|
||||
};
|
||||
clearStatusTimer = setTimeout(setThinkingStatus, 2000, null)
|
||||
}
|
||||
|
||||
if (remainingThinkingTime > 0) {
|
||||
showDurationTimer = setTimeout(showDuration, remainingThinkingTime);
|
||||
showDurationTimer = setTimeout(showDuration, remainingThinkingTime)
|
||||
} else {
|
||||
showDuration();
|
||||
showDuration()
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (showDurationTimer) clearTimeout(showDurationTimer);
|
||||
if (clearStatusTimer) clearTimeout(clearStatusTimer);
|
||||
};
|
||||
}, [mode]);
|
||||
if (showDurationTimer) clearTimeout(showDurationTimer)
|
||||
if (clearStatusTimer) clearTimeout(clearStatusTimer)
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
// Find the current in-progress task and next pending task
|
||||
const currentTodo = tasksV2?.find(task => task.status !== 'pending' && task.status !== 'completed');
|
||||
const nextTask = findNextPendingTask(tasksV2);
|
||||
const currentTodo = tasksV2?.find(
|
||||
task => task.status !== 'pending' && task.status !== 'completed',
|
||||
)
|
||||
const nextTask = findNextPendingTask(tasksV2)
|
||||
|
||||
// Use useState with initializer to pick a random verb once on mount
|
||||
const [randomVerb] = useState(() => sample(getSpinnerVerbs()));
|
||||
const [randomVerb] = useState(() => sample(getSpinnerVerbs()))
|
||||
|
||||
// Leader's own verb (always the leader's, regardless of who is foregrounded)
|
||||
const leaderVerb = overrideMessage ?? currentTodo?.activeForm ?? currentTodo?.subject ?? randomVerb;
|
||||
const leaderVerb =
|
||||
overrideMessage ??
|
||||
currentTodo?.activeForm ??
|
||||
currentTodo?.subject ??
|
||||
randomVerb
|
||||
|
||||
const effectiveVerb =
|
||||
foregroundedTeammate && !foregroundedTeammate.isIdle
|
||||
? (foregroundedTeammate.spinnerVerb ?? randomVerb)
|
||||
: leaderVerb;
|
||||
const message = effectiveVerb + '…';
|
||||
: leaderVerb
|
||||
const message = effectiveVerb + '…'
|
||||
|
||||
// Track CLI activity when spinner is active
|
||||
useEffect(() => {
|
||||
const operationId = 'spinner-' + mode;
|
||||
activityManager.startCLIActivity(operationId);
|
||||
const operationId = 'spinner-' + mode
|
||||
activityManager.startCLIActivity(operationId)
|
||||
return () => {
|
||||
activityManager.endCLIActivity(operationId);
|
||||
};
|
||||
}, [mode]);
|
||||
const effortValue = useAppState(s_4 => s_4.effortValue);
|
||||
const effortSuffix = getEffortSuffix(getMainLoopModel(), effortValue);
|
||||
activityManager.endCLIActivity(operationId)
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
const effortValue = useAppState(s => s.effortValue)
|
||||
const effortSuffix = getEffortSuffix(getMainLoopModel(), effortValue)
|
||||
|
||||
// Check if any running in-process teammates exist (needed for both modes)
|
||||
const runningTeammates = getAllInProcessTeammateTasks(tasks).filter(t => t.status === 'running');
|
||||
const hasRunningTeammates = runningTeammates.length > 0;
|
||||
const allIdle = hasRunningTeammates && runningTeammates.every(t_0 => t_0.isIdle);
|
||||
const runningTeammates = getAllInProcessTeammateTasks(tasks).filter(
|
||||
t => t.status === 'running',
|
||||
)
|
||||
const hasRunningTeammates = runningTeammates.length > 0
|
||||
const allIdle = hasRunningTeammates && runningTeammates.every(t => t.isIdle)
|
||||
|
||||
// Gather aggregate token stats from all running swarm teammates
|
||||
// In spinner-tree mode, skip aggregation (teammates have their own lines in the tree)
|
||||
let teammateTokens = 0;
|
||||
let teammateTokens = 0
|
||||
if (!showSpinnerTree) {
|
||||
for (const task_0 of Object.values(tasks)) {
|
||||
if (isInProcessTeammateTask(task_0) && task_0.status === 'running') {
|
||||
if (task_0.progress?.tokenCount) {
|
||||
teammateTokens += task_0.progress.tokenCount;
|
||||
for (const task of Object.values(tasks)) {
|
||||
if (isInProcessTeammateTask(task) && task.status === 'running') {
|
||||
if (task.progress?.tokenCount) {
|
||||
teammateTokens += task.progress.tokenCount
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,26 +253,33 @@ function SpinnerWithVerbInner({
|
||||
// a coarse 30s threshold.
|
||||
const elapsedSnapshot =
|
||||
pauseStartTimeRef.current !== null
|
||||
? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current
|
||||
: Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current;
|
||||
? pauseStartTimeRef.current -
|
||||
loadingStartTimeRef.current -
|
||||
totalPausedMsRef.current
|
||||
: Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current
|
||||
|
||||
// Leader token count for TeammateSpinnerTree — read raw (non-animated) from
|
||||
// the ref. The tree is only shown when teammates are running; teammate
|
||||
// progress updates to s.tasks trigger re-renders that keep this fresh.
|
||||
const leaderTokenCount = Math.round(responseLengthRef.current / 4);
|
||||
const defaultColor: keyof Theme = 'claude';
|
||||
const defaultShimmerColor = 'claudeShimmer';
|
||||
const messageColor = overrideColor ?? defaultColor;
|
||||
const shimmerColor = overrideShimmerColor ?? defaultShimmerColor;
|
||||
const leaderTokenCount = Math.round(responseLengthRef.current / 4)
|
||||
|
||||
const defaultColor: keyof Theme = 'claude'
|
||||
const defaultShimmerColor = 'claudeShimmer'
|
||||
const messageColor = overrideColor ?? defaultColor
|
||||
const shimmerColor = overrideShimmerColor ?? defaultShimmerColor
|
||||
|
||||
// Compute TTFT string here (off the 50ms animation clock) and pass to
|
||||
// SpinnerAnimationRow so it folds into the `(thought for Ns · ...)` status
|
||||
// line instead of taking a separate row. apiMetricsRef is a ref so this
|
||||
// doesn't trigger re-renders; we pick up updates on the parent's ~25x/turn
|
||||
// re-render cadence, same as the old ApiMetricsLine did.
|
||||
let ttftText: string | null = null;
|
||||
if (process.env.USER_TYPE === 'ant' && apiMetricsRef?.current && apiMetricsRef.current.length > 0) {
|
||||
ttftText = computeTtftText(apiMetricsRef.current);
|
||||
let ttftText: string | null = null
|
||||
if (
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
apiMetricsRef?.current &&
|
||||
apiMetricsRef.current.length > 0
|
||||
) {
|
||||
ttftText = computeTtftText(apiMetricsRef.current)
|
||||
}
|
||||
|
||||
// When leader is idle but teammates are running (and we're viewing the leader),
|
||||
@@ -272,14 +304,14 @@ function SpinnerWithVerbInner({
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// When viewing an idle teammate, show static idle display instead of animated spinner
|
||||
if (foregroundedTeammate?.isIdle) {
|
||||
const idleText = allIdle
|
||||
? `${TEARDROP_ASTERISK} Worked for ${formatDuration(Date.now() - foregroundedTeammate.startTime)}`
|
||||
: `${TEARDROP_ASTERISK} Idle`;
|
||||
: `${TEARDROP_ASTERISK} Idle`
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" alignItems="flex-start">
|
||||
<Box flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
|
||||
@@ -296,46 +328,50 @@ function SpinnerWithVerbInner({
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Time-based tip overrides: coarse thresholds so a stale ref read (we're
|
||||
// off the 50ms clock) is fine. Other triggers (mode change, setMessages)
|
||||
// cause re-renders that refresh this in practice.
|
||||
let contextTipsActive = false;
|
||||
const tipsEnabled = settings.spinnerTipsEnabled !== false;
|
||||
const showClearTip = tipsEnabled && elapsedSnapshot > 1_800_000;
|
||||
const showBtwTip = tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount;
|
||||
let contextTipsActive = false
|
||||
const tipsEnabled = settings.spinnerTipsEnabled !== false
|
||||
const showClearTip = tipsEnabled && elapsedSnapshot > 1_800_000
|
||||
const showBtwTip =
|
||||
tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount
|
||||
|
||||
const effectiveTip = contextTipsActive
|
||||
? undefined
|
||||
: showClearTip && !nextTask
|
||||
? 'Use /clear to start fresh when switching topics and free up context'
|
||||
: showBtwTip && !nextTask
|
||||
? "Use /btw to ask a quick side question without interrupting Claude's current work"
|
||||
: spinnerTip;
|
||||
: spinnerTip
|
||||
|
||||
// Budget text (ant-only) — shown above the tip line
|
||||
let budgetText: string | null = null;
|
||||
let budgetText: string | null = null
|
||||
if (feature('TOKEN_BUDGET')) {
|
||||
const budget = getCurrentTurnTokenBudget();
|
||||
const budget = getCurrentTurnTokenBudget()
|
||||
if (budget !== null && budget > 0) {
|
||||
const tokens = getTurnOutputTokens();
|
||||
const tokens = getTurnOutputTokens()
|
||||
if (tokens >= budget) {
|
||||
budgetText = `Target: ${formatNumber(tokens)} used (${formatNumber(budget)} min ${figures.tick})`;
|
||||
budgetText = `Target: ${formatNumber(tokens)} used (${formatNumber(budget)} min ${figures.tick})`
|
||||
} else {
|
||||
const pct = Math.round((tokens / budget) * 100);
|
||||
const remaining = budget - tokens;
|
||||
const rate = elapsedSnapshot > 5000 && tokens >= 2000 ? tokens / elapsedSnapshot : 0;
|
||||
const pct = Math.round((tokens / budget) * 100)
|
||||
const remaining = budget - tokens
|
||||
const rate =
|
||||
elapsedSnapshot > 5000 && tokens >= 2000
|
||||
? tokens / elapsedSnapshot
|
||||
: 0
|
||||
const eta =
|
||||
rate > 0
|
||||
? ` \u00B7 ~${formatDuration(remaining / rate, {
|
||||
mostSignificantOnly: true,
|
||||
})}`
|
||||
: '';
|
||||
budgetText = `Target: ${formatNumber(tokens)} / ${formatNumber(budget)} (${pct}%)${eta}`;
|
||||
? ` \u00B7 ~${formatDuration(remaining / rate, { mostSignificantOnly: true })}`
|
||||
: ''
|
||||
budgetText = `Target: ${formatNumber(tokens)} / ${formatNumber(budget)} (${pct}%)${eta}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" alignItems="flex-start">
|
||||
<SpinnerAnimationRow
|
||||
@@ -387,13 +423,17 @@ function SpinnerWithVerbInner({
|
||||
)}
|
||||
{(nextTask || effectiveTip) && (
|
||||
<MessageResponse>
|
||||
<Text dimColor>{nextTask ? `Next: ${nextTask.subject}` : `Tip: ${effectiveTip}`}</Text>
|
||||
<Text dimColor>
|
||||
{nextTask
|
||||
? `Next: ${nextTask.subject}`
|
||||
: `Tip: ${effectiveTip}`}
|
||||
</Text>
|
||||
</MessageResponse>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Brief/assistant mode spinner: single status line. PromptInput drops its
|
||||
@@ -406,288 +446,173 @@ function SpinnerWithVerbInner({
|
||||
// spinner, not over the spinner content. Paired with BriefIdleStatus which
|
||||
// keeps the same footprint when idle.
|
||||
type BriefSpinnerProps = {
|
||||
mode: SpinnerMode;
|
||||
overrideMessage?: string | null;
|
||||
};
|
||||
function BriefSpinner(t0) {
|
||||
const $ = _c(31);
|
||||
const { mode, overrideMessage } = t0;
|
||||
const settings = useSettings();
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false;
|
||||
const [randomVerb] = useState(_temp4);
|
||||
const verb = overrideMessage ?? randomVerb;
|
||||
const connStatus = useAppState(_temp5);
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[0] !== mode) {
|
||||
t1 = () => {
|
||||
const operationId = 'spinner-' + mode;
|
||||
activityManager.startCLIActivity(operationId);
|
||||
return () => {
|
||||
activityManager.endCLIActivity(operationId);
|
||||
};
|
||||
};
|
||||
t2 = [mode];
|
||||
$[0] = mode;
|
||||
$[1] = t1;
|
||||
$[2] = t2;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
t2 = $[2];
|
||||
}
|
||||
useEffect(t1, t2);
|
||||
const [, time] = useAnimationFrame(reducedMotion ? null : 120);
|
||||
const runningCount = useAppState(_temp6);
|
||||
const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected';
|
||||
const connText = connStatus === 'reconnecting' ? 'Reconnecting' : 'Disconnected';
|
||||
const dotFrame = Math.floor(time / 300) % 3;
|
||||
let t3;
|
||||
if ($[3] !== dotFrame || $[4] !== reducedMotion) {
|
||||
t3 = reducedMotion ? '\u2026 ' : '.'.repeat(dotFrame + 1).padEnd(3);
|
||||
$[3] = dotFrame;
|
||||
$[4] = reducedMotion;
|
||||
$[5] = t3;
|
||||
} else {
|
||||
t3 = $[5];
|
||||
}
|
||||
const dots = t3;
|
||||
let t4;
|
||||
if ($[6] !== verb) {
|
||||
t4 = stringWidth(verb);
|
||||
$[6] = verb;
|
||||
$[7] = t4;
|
||||
} else {
|
||||
t4 = $[7];
|
||||
}
|
||||
const verbWidth = t4;
|
||||
let t5;
|
||||
if ($[8] !== reducedMotion || $[9] !== showConnWarning || $[10] !== time || $[11] !== verb || $[12] !== verbWidth) {
|
||||
const glimmerIndex =
|
||||
reducedMotion || showConnWarning ? -100 : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth);
|
||||
t5 = computeShimmerSegments(verb, glimmerIndex);
|
||||
$[8] = reducedMotion;
|
||||
$[9] = showConnWarning;
|
||||
$[10] = time;
|
||||
$[11] = verb;
|
||||
$[12] = verbWidth;
|
||||
$[13] = t5;
|
||||
} else {
|
||||
t5 = $[13];
|
||||
}
|
||||
const { before, shimmer, after } = t5;
|
||||
const { columns } = useTerminalSize();
|
||||
const rightText = runningCount > 0 ? `${runningCount} in background` : '';
|
||||
let t6;
|
||||
if ($[14] !== connText || $[15] !== showConnWarning || $[16] !== verbWidth) {
|
||||
t6 = showConnWarning ? stringWidth(connText) : verbWidth;
|
||||
$[14] = connText;
|
||||
$[15] = showConnWarning;
|
||||
$[16] = verbWidth;
|
||||
$[17] = t6;
|
||||
} else {
|
||||
t6 = $[17];
|
||||
}
|
||||
const leftWidth = t6 + 3;
|
||||
const pad = Math.max(1, columns - 2 - leftWidth - stringWidth(rightText));
|
||||
let t7;
|
||||
if (
|
||||
$[18] !== after ||
|
||||
$[19] !== before ||
|
||||
$[20] !== connText ||
|
||||
$[21] !== dots ||
|
||||
$[22] !== shimmer ||
|
||||
$[23] !== showConnWarning
|
||||
) {
|
||||
t7 = showConnWarning ? (
|
||||
<Text color="error">{connText + dots}</Text>
|
||||
) : (
|
||||
<>
|
||||
{before ? <Text dimColor={true}>{before}</Text> : null}
|
||||
{shimmer ? <Text>{shimmer}</Text> : null}
|
||||
{after ? <Text dimColor={true}>{after}</Text> : null}
|
||||
<Text dimColor={true}>{dots}</Text>
|
||||
</>
|
||||
);
|
||||
$[18] = after;
|
||||
$[19] = before;
|
||||
$[20] = connText;
|
||||
$[21] = dots;
|
||||
$[22] = shimmer;
|
||||
$[23] = showConnWarning;
|
||||
$[24] = t7;
|
||||
} else {
|
||||
t7 = $[24];
|
||||
}
|
||||
let t8;
|
||||
if ($[25] !== pad || $[26] !== rightText) {
|
||||
t8 = rightText ? (
|
||||
<>
|
||||
<Text>{' '.repeat(pad)}</Text>
|
||||
<Text color="subtle">{rightText}</Text>
|
||||
</>
|
||||
) : null;
|
||||
$[25] = pad;
|
||||
$[26] = rightText;
|
||||
$[27] = t8;
|
||||
} else {
|
||||
t8 = $[27];
|
||||
}
|
||||
let t9;
|
||||
if ($[28] !== t7 || $[29] !== t8) {
|
||||
t9 = (
|
||||
<Box flexDirection="row" width="100%" marginTop={1} paddingLeft={2}>
|
||||
{t7}
|
||||
{t8}
|
||||
</Box>
|
||||
);
|
||||
$[28] = t7;
|
||||
$[29] = t8;
|
||||
$[30] = t9;
|
||||
} else {
|
||||
t9 = $[30];
|
||||
}
|
||||
return t9;
|
||||
mode: SpinnerMode
|
||||
overrideMessage?: string | null
|
||||
}
|
||||
|
||||
function BriefSpinner({
|
||||
mode,
|
||||
overrideMessage,
|
||||
}: BriefSpinnerProps): React.ReactNode {
|
||||
const settings = useSettings()
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false
|
||||
const [randomVerb] = useState(() => sample(getSpinnerVerbs()) ?? 'Working')
|
||||
const verb = overrideMessage ?? randomVerb
|
||||
const connStatus = useAppState(s => s.remoteConnectionStatus)
|
||||
|
||||
// Track CLI activity so OS/IDE "busy" indicators fire in brief mode too
|
||||
useEffect(() => {
|
||||
const operationId = 'spinner-' + mode
|
||||
activityManager.startCLIActivity(operationId)
|
||||
return () => {
|
||||
activityManager.endCLIActivity(operationId)
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
// Drive both dot cycle and shimmer from the shared clock. The viewport
|
||||
// ref is unused — the spinner unmounts on turn end so viewport-based
|
||||
// pausing isn't needed.
|
||||
const [, time] = useAnimationFrame(reducedMotion ? null : 120)
|
||||
|
||||
// Local tasks + remote tasks are mutually exclusive (viewer mode has an
|
||||
// empty local AppState.tasks; local mode has remoteBackgroundTaskCount=0).
|
||||
// Summing avoids a mode branch.
|
||||
const runningCount = useAppState(
|
||||
s =>
|
||||
count(Object.values(s.tasks), isBackgroundTask) +
|
||||
s.remoteBackgroundTaskCount,
|
||||
)
|
||||
|
||||
// Connection trouble overrides the verb — `claude assistant` is a pure viewer,
|
||||
// nothing useful is happening while the WS is down.
|
||||
const showConnWarning =
|
||||
connStatus === 'reconnecting' || connStatus === 'disconnected'
|
||||
const connText =
|
||||
connStatus === 'reconnecting' ? 'Reconnecting' : 'Disconnected'
|
||||
|
||||
// Dots padded to a fixed 3 columns so the right-aligned count doesn't
|
||||
// jitter as the cycle advances.
|
||||
const dotFrame = Math.floor(time / 300) % 3
|
||||
const dots = reducedMotion ? '… ' : '.'.repeat(dotFrame + 1).padEnd(3)
|
||||
|
||||
// Shimmer: reverse-sweep highlight across the verb. Skip for connection
|
||||
// warnings (shimmer reads as "working"; Reconnecting/Disconnected is not).
|
||||
const verbWidth = useMemo(() => stringWidth(verb), [verb])
|
||||
const glimmerIndex =
|
||||
reducedMotion || showConnWarning
|
||||
? -100
|
||||
: computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth)
|
||||
const { before, shimmer, after } = computeShimmerSegments(verb, glimmerIndex)
|
||||
|
||||
const { columns } = useTerminalSize()
|
||||
const rightText = runningCount > 0 ? `${runningCount} in background` : ''
|
||||
// Manual right-align via space padding — flexGrow spacers inside
|
||||
// FullscreenLayout's `main` slot don't resolve a width and caused the
|
||||
// diff engine to miss dot-frame updates.
|
||||
const leftWidth = (showConnWarning ? stringWidth(connText) : verbWidth) + 3
|
||||
const pad = Math.max(1, columns - 2 - leftWidth - stringWidth(rightText))
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width="100%" marginTop={1} paddingLeft={2}>
|
||||
{showConnWarning ? (
|
||||
<Text color="error">{connText + dots}</Text>
|
||||
) : (
|
||||
<>
|
||||
{before ? <Text dimColor>{before}</Text> : null}
|
||||
{shimmer ? <Text>{shimmer}</Text> : null}
|
||||
{after ? <Text dimColor>{after}</Text> : null}
|
||||
<Text dimColor>{dots}</Text>
|
||||
</>
|
||||
)}
|
||||
{rightText ? (
|
||||
<>
|
||||
<Text>{' '.repeat(pad)}</Text>
|
||||
<Text color="subtle">{rightText}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Idle placeholder for brief mode. Same 2-row [blank, content] footprint
|
||||
// as BriefSpinner so the input bar never jumps when toggling between
|
||||
// working/idle/disconnected. See BriefSpinner's comment for the
|
||||
// Notifications overlay coupling.
|
||||
function _temp6(s_0) {
|
||||
return count(Object.values(s_0.tasks), isBackgroundTask) + s_0.remoteBackgroundTaskCount;
|
||||
export function BriefIdleStatus(): React.ReactNode {
|
||||
const connStatus = useAppState(s => s.remoteConnectionStatus)
|
||||
const runningCount = useAppState(
|
||||
s =>
|
||||
count(Object.values(s.tasks), isBackgroundTask) +
|
||||
s.remoteBackgroundTaskCount,
|
||||
)
|
||||
const { columns } = useTerminalSize()
|
||||
|
||||
const showConnWarning =
|
||||
connStatus === 'reconnecting' || connStatus === 'disconnected'
|
||||
const connText =
|
||||
connStatus === 'reconnecting' ? 'Reconnecting…' : 'Disconnected'
|
||||
const leftText = showConnWarning ? connText : ''
|
||||
const rightText = runningCount > 0 ? `${runningCount} in background` : ''
|
||||
|
||||
if (!leftText && !rightText) return <Box height={2} />
|
||||
|
||||
const pad = Math.max(
|
||||
1,
|
||||
columns - 2 - stringWidth(leftText) - stringWidth(rightText),
|
||||
)
|
||||
return (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text>
|
||||
{leftText ? <Text color="error">{leftText}</Text> : null}
|
||||
{rightText ? (
|
||||
<>
|
||||
<Text>{' '.repeat(pad)}</Text>
|
||||
<Text color="subtle">{rightText}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
function _temp5(s) {
|
||||
return s.remoteConnectionStatus;
|
||||
}
|
||||
function _temp4() {
|
||||
return sample(getSpinnerVerbs()) ?? 'Working';
|
||||
}
|
||||
export function BriefIdleStatus() {
|
||||
const $ = _c(9);
|
||||
const connStatus = useAppState(_temp7);
|
||||
const runningCount = useAppState(_temp8);
|
||||
const { columns } = useTerminalSize();
|
||||
const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected';
|
||||
const connText = connStatus === 'reconnecting' ? 'Reconnecting\u2026' : 'Disconnected';
|
||||
const leftText = showConnWarning ? connText : '';
|
||||
const rightText = runningCount > 0 ? `${runningCount} in background` : '';
|
||||
if (!leftText && !rightText) {
|
||||
let t0;
|
||||
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t0 = <Box height={2} />;
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
return t0;
|
||||
}
|
||||
const pad = Math.max(1, columns - 2 - stringWidth(leftText) - stringWidth(rightText));
|
||||
let t0;
|
||||
if ($[1] !== leftText) {
|
||||
t0 = leftText ? <Text color="error">{leftText}</Text> : null;
|
||||
$[1] = leftText;
|
||||
$[2] = t0;
|
||||
} else {
|
||||
t0 = $[2];
|
||||
}
|
||||
let t1;
|
||||
if ($[3] !== pad || $[4] !== rightText) {
|
||||
t1 = rightText ? (
|
||||
<>
|
||||
<Text>{' '.repeat(pad)}</Text>
|
||||
<Text color="subtle">{rightText}</Text>
|
||||
</>
|
||||
) : null;
|
||||
$[3] = pad;
|
||||
$[4] = rightText;
|
||||
$[5] = t1;
|
||||
} else {
|
||||
t1 = $[5];
|
||||
}
|
||||
let t2;
|
||||
if ($[6] !== t0 || $[7] !== t1) {
|
||||
t2 = (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text>
|
||||
{t0}
|
||||
{t1}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
$[6] = t0;
|
||||
$[7] = t1;
|
||||
$[8] = t2;
|
||||
} else {
|
||||
t2 = $[8];
|
||||
}
|
||||
return t2;
|
||||
}
|
||||
function _temp8(s_0) {
|
||||
return count(Object.values(s_0.tasks), isBackgroundTask) + s_0.remoteBackgroundTaskCount;
|
||||
}
|
||||
function _temp7(s) {
|
||||
return s.remoteConnectionStatus;
|
||||
}
|
||||
export function Spinner() {
|
||||
const $ = _c(8);
|
||||
const settings = useSettings();
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false;
|
||||
const [ref, time] = useAnimationFrame(reducedMotion ? null : 120);
|
||||
|
||||
export function Spinner(): React.ReactNode {
|
||||
const settings = useSettings()
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false
|
||||
const [ref, time] = useAnimationFrame(reducedMotion ? null : 120)
|
||||
|
||||
// Reduced motion: static dot instead of animated spinner
|
||||
if (reducedMotion) {
|
||||
let t0;
|
||||
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t0 = <Text color="text">●</Text>;
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
let t1;
|
||||
if ($[1] !== ref) {
|
||||
t1 = (
|
||||
<Box ref={ref} flexWrap="wrap" height={1} width={2}>
|
||||
{t0}
|
||||
</Box>
|
||||
);
|
||||
$[1] = ref;
|
||||
$[2] = t1;
|
||||
} else {
|
||||
t1 = $[2];
|
||||
}
|
||||
return t1;
|
||||
}
|
||||
const frame = Math.floor(time / 120) % SPINNER_FRAMES.length;
|
||||
const t0 = SPINNER_FRAMES[frame];
|
||||
let t1;
|
||||
if ($[3] !== t0) {
|
||||
t1 = <Text color="text">{t0}</Text>;
|
||||
$[3] = t0;
|
||||
$[4] = t1;
|
||||
} else {
|
||||
t1 = $[4];
|
||||
}
|
||||
let t2;
|
||||
if ($[5] !== ref || $[6] !== t1) {
|
||||
t2 = (
|
||||
return (
|
||||
<Box ref={ref} flexWrap="wrap" height={1} width={2}>
|
||||
{t1}
|
||||
<Text color="text">●</Text>
|
||||
</Box>
|
||||
);
|
||||
$[5] = ref;
|
||||
$[6] = t1;
|
||||
$[7] = t2;
|
||||
} else {
|
||||
t2 = $[7];
|
||||
)
|
||||
}
|
||||
return t2;
|
||||
|
||||
// Derive frame from synced time - all spinners animate together
|
||||
const frame = Math.floor(time / 120) % SPINNER_FRAMES.length
|
||||
|
||||
return (
|
||||
<Box ref={ref} flexWrap="wrap" height={1} width={2}>
|
||||
<Text color="text">{SPINNER_FRAMES[frame]}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function findNextPendingTask(tasks: Task[] | undefined): Task | undefined {
|
||||
if (!tasks) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
const pendingTasks = tasks.filter(t => t.status === 'pending');
|
||||
const pendingTasks = tasks.filter(t => t.status === 'pending')
|
||||
if (pendingTasks.length === 0) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
const unresolvedIds = new Set(tasks.filter(t => t.status !== 'completed').map(t => t.id));
|
||||
return pendingTasks.find(t => !t.blockedBy.some(id => unresolvedIds.has(id))) ?? pendingTasks[0];
|
||||
const unresolvedIds = new Set(
|
||||
tasks.filter(t => t.status !== 'completed').map(t => t.id),
|
||||
)
|
||||
return (
|
||||
pendingTasks.find(t => !t.blockedBy.some(id => unresolvedIds.has(id))) ??
|
||||
pendingTasks[0]
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user