mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 14:16:58 +10:00
240 lines
32 KiB
TypeScript
240 lines
32 KiB
TypeScript
|
|
import type * as React from 'react';
|
||
|
|
import { useCallback, useEffect } from 'react';
|
||
|
|
import { useAppStateStore, useSetAppState } from 'src/state/AppState.js';
|
||
|
|
import type { Theme } from '../utils/theme.js';
|
||
|
|
type Priority = 'low' | 'medium' | 'high' | 'immediate';
|
||
|
|
type BaseNotification = {
|
||
|
|
key: string;
|
||
|
|
/**
|
||
|
|
* Keys of notifications that this notification invalidates.
|
||
|
|
* If a notification is invalidated, it will be removed from the queue
|
||
|
|
* and, if currently displayed, cleared immediately.
|
||
|
|
*/
|
||
|
|
invalidates?: string[];
|
||
|
|
priority: Priority;
|
||
|
|
timeoutMs?: number;
|
||
|
|
/**
|
||
|
|
* Combine notifications with the same key, like Array.reduce().
|
||
|
|
* Called as fold(accumulator, incoming) when a notification with a matching
|
||
|
|
* key already exists in the queue or is currently displayed.
|
||
|
|
* Returns the merged notification (should carry fold forward for future merges).
|
||
|
|
*/
|
||
|
|
fold?: (accumulator: Notification, incoming: Notification) => Notification;
|
||
|
|
};
|
||
|
|
type TextNotification = BaseNotification & {
|
||
|
|
text: string;
|
||
|
|
color?: keyof Theme;
|
||
|
|
};
|
||
|
|
type JSXNotification = BaseNotification & {
|
||
|
|
jsx: React.ReactNode;
|
||
|
|
};
|
||
|
|
type AddNotificationFn = (content: Notification) => void;
|
||
|
|
type RemoveNotificationFn = (key: string) => void;
|
||
|
|
export type Notification = TextNotification | JSXNotification;
|
||
|
|
const DEFAULT_TIMEOUT_MS = 8000;
|
||
|
|
|
||
|
|
// Track current timeout to clear it when immediate notifications arrive
|
||
|
|
let currentTimeoutId: NodeJS.Timeout | null = null;
|
||
|
|
export function useNotifications(): {
|
||
|
|
addNotification: AddNotificationFn;
|
||
|
|
removeNotification: RemoveNotificationFn;
|
||
|
|
} {
|
||
|
|
const store = useAppStateStore();
|
||
|
|
const setAppState = useSetAppState();
|
||
|
|
|
||
|
|
// Process queue when current notification finishes or queue changes
|
||
|
|
const processQueue = useCallback(() => {
|
||
|
|
setAppState(prev => {
|
||
|
|
const next = getNext(prev.notifications.queue);
|
||
|
|
if (prev.notifications.current !== null || !next) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
currentTimeoutId = setTimeout((setAppState, nextKey, processQueue) => {
|
||
|
|
currentTimeoutId = null;
|
||
|
|
setAppState(prev => {
|
||
|
|
// Compare by key instead of reference to handle re-created notifications
|
||
|
|
if (prev.notifications.current?.key !== nextKey) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
notifications: {
|
||
|
|
queue: prev.notifications.queue,
|
||
|
|
current: null
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
processQueue();
|
||
|
|
}, next.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, next.key, processQueue);
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
notifications: {
|
||
|
|
queue: prev.notifications.queue.filter(_ => _ !== next),
|
||
|
|
current: next
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}, [setAppState]);
|
||
|
|
const addNotification = useCallback<AddNotificationFn>((notif: Notification) => {
|
||
|
|
// Handle immediate priority notifications
|
||
|
|
if (notif.priority === 'immediate') {
|
||
|
|
// Clear any existing timeout since we're showing a new immediate notification
|
||
|
|
if (currentTimeoutId) {
|
||
|
|
clearTimeout(currentTimeoutId);
|
||
|
|
currentTimeoutId = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Set up timeout for the immediate notification
|
||
|
|
currentTimeoutId = setTimeout((setAppState, notif, processQueue) => {
|
||
|
|
currentTimeoutId = null;
|
||
|
|
setAppState(prev => {
|
||
|
|
// Compare by key instead of reference to handle re-created notifications
|
||
|
|
if (prev.notifications.current?.key !== notif.key) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
notifications: {
|
||
|
|
queue: prev.notifications.queue.filter(_ => !notif.invalidates?.includes(_.key)),
|
||
|
|
current: null
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
processQueue();
|
||
|
|
}, notif.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, notif, processQueue);
|
||
|
|
|
||
|
|
// Show the immediate notification right away
|
||
|
|
setAppState(prev => ({
|
||
|
|
...prev,
|
||
|
|
notifications: {
|
||
|
|
current: notif,
|
||
|
|
queue:
|
||
|
|
// Only re-queue the current notification if it's not immediate
|
||
|
|
[...(prev.notifications.current ? [prev.notifications.current] : []), ...prev.notifications.queue].filter(_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key))
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
return; // IMPORTANT: Exit addNotification for immediate notifications
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle non-immediate notifications
|
||
|
|
setAppState(prev => {
|
||
|
|
// Check if we can fold into an existing notification with the same key
|
||
|
|
if (notif.fold) {
|
||
|
|
// Fold into current notification if keys match
|
||
|
|
if (prev.notifications.current?.key === notif.key) {
|
||
|
|
const folded = notif.fold(prev.notifications.current, notif);
|
||
|
|
// Reset timeout for the folded notification
|
||
|
|
if (currentTimeoutId) {
|
||
|
|
clearTimeout(currentTimeoutId);
|
||
|
|
currentTimeoutId = null;
|
||
|
|
}
|
||
|
|
currentTimeoutId = setTimeout((setAppState, foldedKey, processQueue) => {
|
||
|
|
currentTimeoutId = null;
|
||
|
|
setAppState(p => {
|
||
|
|
if (p.notifications.current?.key !== foldedKey) {
|
||
|
|
return p;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...p,
|
||
|
|
notifications: {
|
||
|
|
queue: p.notifications.queue,
|
||
|
|
current: null
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
processQueue();
|
||
|
|
}, folded.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, folded.key, processQueue);
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
notifications: {
|
||
|
|
current: folded,
|
||
|
|
queue: prev.notifications.queue
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fold into queued notification if keys match
|
||
|
|
const queueIdx = prev.notifications.queue.findIndex(_ => _.key === notif.key);
|
||
|
|
if (queueIdx !== -1) {
|
||
|
|
const folded = notif.fold(prev.notifications.queue[queueIdx]!, notif);
|
||
|
|
const newQueue = [...prev.notifications.queue];
|
||
|
|
newQueue[queueIdx] = folded;
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
notifications: {
|
||
|
|
current: prev.notifications.current,
|
||
|
|
queue: newQueue
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Only add to queue if not already present (prevent duplicates)
|
||
|
|
const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key));
|
||
|
|
const shouldAdd = !queuedKeys.has(notif.key) && prev.notifications.current?.key !== notif.key;
|
||
|
|
if (!shouldAdd) return prev;
|
||
|
|
const invalidatesCurrent = prev.notifications.current !== null && notif.invalidates?.includes(prev.notifications.current.key);
|
||
|
|
if (invalidatesCurrent && currentTimeoutId) {
|
||
|
|
clearTimeout(currentTimeoutId);
|
||
|
|
currentTimeoutId = null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
notifications: {
|
||
|
|
current: invalidatesCurrent ? null : prev.notifications.current,
|
||
|
|
queue: [...prev.notifications.queue.filter(_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key)), notif]
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// Process queue after adding the notification
|
||
|
|
processQueue();
|
||
|
|
}, [setAppState, processQueue]);
|
||
|
|
const removeNotification = useCallback<RemoveNotificationFn>((key: string) => {
|
||
|
|
setAppState(prev => {
|
||
|
|
const isCurrent = prev.notifications.current?.key === key;
|
||
|
|
const inQueue = prev.notifications.queue.some(n => n.key === key);
|
||
|
|
if (!isCurrent && !inQueue) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
if (isCurrent && currentTimeoutId) {
|
||
|
|
clearTimeout(currentTimeoutId);
|
||
|
|
currentTimeoutId = null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
notifications: {
|
||
|
|
current: isCurrent ? null : prev.notifications.current,
|
||
|
|
queue: prev.notifications.queue.filter(n => n.key !== key)
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
processQueue();
|
||
|
|
}, [setAppState, processQueue]);
|
||
|
|
|
||
|
|
// Process queue on mount if there are notifications in the initial state.
|
||
|
|
// Imperative read (not useAppState) — a subscription in a mount-only
|
||
|
|
// effect would be vestigial and make every caller re-render on queue changes.
|
||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect, store is a stable context ref
|
||
|
|
useEffect(() => {
|
||
|
|
if (store.getState().notifications.queue.length > 0) {
|
||
|
|
processQueue();
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
return {
|
||
|
|
addNotification,
|
||
|
|
removeNotification
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const PRIORITIES: Record<Priority, number> = {
|
||
|
|
immediate: 0,
|
||
|
|
high: 1,
|
||
|
|
medium: 2,
|
||
|
|
low: 3
|
||
|
|
};
|
||
|
|
export function getNext(queue: Notification[]): Notification | undefined {
|
||
|
|
if (queue.length === 0) return undefined;
|
||
|
|
return queue.reduce((min, n) => PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min);
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUNhbGxiYWNrIiwidXNlRWZmZWN0IiwidXNlQXBwU3RhdGVTdG9yZSIsInVzZVNldEFwcFN0YXRlIiwiVGhlbWUiLCJQcmlvcml0eSIsIkJhc2VOb3RpZmljYXRpb24iLCJrZXkiLCJpbnZhbGlkYXRlcyIsInByaW9yaXR5IiwidGltZW91dE1zIiwiZm9sZCIsImFjY3VtdWxhdG9yIiwiTm90aWZpY2F0aW9uIiwiaW5jb21pbmciLCJUZXh0Tm90aWZpY2F0aW9uIiwidGV4dCIsImNvbG9yIiwiSlNYTm90aWZpY2F0aW9uIiwianN4IiwiUmVhY3ROb2RlIiwiQWRkTm90aWZpY2F0aW9uRm4iLCJjb250ZW50IiwiUmVtb3ZlTm90aWZpY2F0aW9uRm4iLCJERUZBVUxUX1RJTUVPVVRfTVMiLCJjdXJyZW50VGltZW91dElkIiwiTm9kZUpTIiwiVGltZW91dCIsInVzZU5vdGlmaWNhdGlvbnMiLCJhZGROb3RpZmljYXRpb24iLCJyZW1vdmVOb3RpZmljYXRpb24iLCJzdG9yZSIsInNldEFwcFN0YXRlIiwicHJvY2Vzc1F1ZXVlIiwicHJldiIsIm5leHQiLCJnZXROZXh0Iiwibm90aWZpY2F0aW9ucyIsInF1ZXVlIiwiY3VycmVudCIsInNldFRpbWVvdXQiLCJuZXh0S2V5IiwiZmlsdGVyIiwiXyIsIm5vdGlmIiwiY2xlYXJUaW1lb3V0IiwiaW5jbHVkZXMiLCJmb2xkZWQiLCJmb2xkZWRLZXkiLCJwIiwicXVldWVJZHgiLCJmaW5kSW5kZXgiLCJuZXdRdWV1ZSIsInF1ZXVlZEtleXMiLCJTZXQiLCJtYXAiLCJzaG91bGRBZGQiLCJoYXMiLCJpbnZhbGlkYXRlc0N1cnJlbnQiLCJpc0N1cnJlbnQiLCJpblF1ZXVlIiwic29tZSIsIm4iLCJnZXRTdGF0ZSIsImxlbmd0aCIsIlBSSU9SSVRJRVMiLCJSZWNvcmQiLCJpbW1lZGlhdGUiLCJoaWdoIiwibWVkaXVtIiwibG93IiwidW5kZWZpbmVkIiwicmVkdWNlIiwibWluIl0sInNvdXJjZXMiOlsibm90aWZpY2F0aW9ucy50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHR5cGUgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZUNhbGxiYWNrLCB1c2VFZmZlY3QgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZUFwcFN0YXRlU3RvcmUsIHVzZVNldEFwcFN0YXRlIH0gZnJvbSAnc3JjL3N0YXRlL0FwcFN0YXRlLmpzJ1xuaW1wb3J0IHR5cGUgeyBUaGVtZSB9IGZyb20gJy4uL3V0aWxzL3RoZW1lLmpzJ1xuXG50eXBlIFByaW9yaXR5ID0gJ2xvdycgfCAnbWVkaXVtJyB8ICdoaWdoJyB8ICdpbW1lZGlhdGUnXG5cbnR5cGUgQmFzZU5vdGlmaWNhdGlvbiA9IHtcbiAga2V5OiBzdHJpbmdcbiAgLyoqXG4gICAqIEtleXMgb2Ygbm90aWZpY2F0aW9ucyB0aGF0IHRoaXMgbm90aWZpY2F0aW9uIGludmFsaWRhdGVzLlxuICAgKiBJZiBhIG5vdGlmaWNhdGlvbiBpcyBpbnZhbGlkYXRlZCwgaXQgd2lsbCBiZSByZW1vdmVkIGZyb20gdGhlIHF1ZXVlXG4gICAqIGFuZCwgaWYgY3VycmVudGx5IGRpc3BsYXllZCwgY2xlYXJlZCBpbW1lZGlhdGVseS5cbiAgICovXG4gIGludmFsaWRhdGVzPzogc3RyaW5nW11cbiAgcHJpb3JpdHk6IFByaW9yaXR5XG4gIHRpbWVvdXRNcz86IG51bWJlclxuICAvKipcbiAgICogQ29tYmluZSBub3RpZmljYXRpb25zIHdpdGggdGhlIHNhbWUga2V5LCBsaWtlIEFycmF5LnJlZHVjZSgpLlxuICAgKiBDYWxsZWQgYXMgZm9sZChhY2N1bXVsYXRvciwgaW5jb21pbmcpIHdoZW4gYSBub3RpZmljYXRpb24gd2l0aCBhIG1hdGNoaW5nXG4gICAqIGtleSBhbHJlYWR5IGV4aXN0cyBpbiB0aGUgcXVldWUgb3IgaXMgY3VycmVudGx5IGRpc3BsYXllZC5cbiAgICogUmV0dXJucyB0aGUgbWVyZ2VkIG5vdGlmaWNhdGlvbiAoc2hvdWxkIGNhcnJ5IGZvbGQgZm9yd2FyZCBmb3IgZnV0dXJlIG1lcmdlcykuXG4gICAqL1xuICBmb2xkPzogKGFjY3VtdWxhdG9yOiBOb3RpZmljYXRpb24sIGluY29taW5nOiBOb3RpZmljYXRpb24pID0+IE5vdGlmaWNhdGlvblxufVxuXG50eXBlIFRleHROb3RpZmljYXRpb24gPSBCYXNlTm90aWZpY2F0aW9uICYge1xuICB0ZXh0OiBzdHJpbmdcbiAgY29sb3I/OiBrZXlvZiBUaGVtZVxufVxuXG50eXBlIEpTWE5vdGlmaWNhdGlvbiA9IEJhc2VOb3RpZmljYXRpb24gJiB7XG4gIGpzeDogUmVhY3QuUmVhY3ROb2RlXG59XG5cbnR5cGUgQWRkTm90aWZpY2F0aW9uRm4gPSAoY29udGVudDogTm90aWZpY2F0aW9uKSA9PiB2b2lkXG50eXBlIFJlbW92ZU5vdGlmaWNhdGlvbkZuID0gKGtleTogc3RyaW5nKSA9PiB2b2lkXG5cbmV4cG9ydCB0eXBlIE5vdGlmaWNhdGlvbiA9IFRleHROb3RpZmljYXRpb24gfCBKU1hOb3RpZmljYXRpb25cblxuY29uc3QgREVGQVVMVF9USU1FT1VUX01TID0gODAwMFxuXG4vLyBUcmFjayBjdXJyZW50IHRpbWVvdXQgdG8gY2xlYXIgaXQgd2hlbiBpbW1lZGlhdGUgbm90aWZpY2F0aW9ucyBhcnJpdmVcbmxldCBjdXJyZW50VGltZW91dElkOiBOb2RlSlMuVGltZW91dCB8IG51bGwgPSBudWxsXG5cbmV4cG9ydCBmdW5jdGlvbiB1c2VOb3RpZmljYXRpb25zKCk6IHtcbiAgYWRkTm90aWZpY2F0aW9uOiBBZGROb3RpZmljYXRpb25GblxuICByZW1vdmVOb3RpZmljYXRpb246IFJlbW92ZU5vdGlmaWNhdGlvbkZuXG59IHtcbiAgY29uc3Qgc3RvcmUgPSB1c2VBcHBTdGF0ZVN0b3JlKClcbiAgY29uc3Qgc2V0QXBwU3RhdGUgPSB1c2VTZXRBcHBTdGF0ZSgpXG5cbiAgLy8gUHJvY2VzcyBxdWV1ZSB3aGVuIGN1cnJlbnQgbm90aWZpY2F0aW9uIGZpbmlzaGVzIG9yIHF1ZXVlIGNoYW5nZXNcbiAgY29uc3QgcHJvY2Vzc1F1ZXVlID0gdXNlQ2FsbGJhY2soKCkgPT4ge1xuICAgIHNldEFwcFN0YXRlKHByZXYgPT4ge1xuICAgICAgY29uc3QgbmV4dCA9IGdldE5leHQocHJldi5ub3RpZmljYXRpb25zLnF1ZXVlKVxuICAgICAgaWYgKHByZXYubm90aWZpY2F0aW9ucy5jdXJyZW50ICE9PSBudWxsIHx8ICFuZXh0KSB7XG4gICAgICAgIHJldHVybiBwcmV2XG4gICAgICB9XG5cbiAgICAgIGN1cnJlbnRUaW1lb3V0SWQgPSBzZXRUaW1lb3V0KFxuICAgICAgICAoc2V0QXBwU3RhdGUsIG5leHRLZXksIHByb2Nlc3NRdWV1ZSkgPT4ge1xuICAgICAgICAgIGN1cnJlbnRUaW1
|