@steerable/agent-ui 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cards/ActionSegmentCard.d.ts +18 -0
- package/dist/cards/ActionSegmentCard.d.ts.map +1 -0
- package/dist/cards/ActionSegmentCard.js +26 -0
- package/dist/cards/ActionSegmentCard.js.map +1 -0
- package/dist/cards/AnalysisDocumentCard.d.ts +33 -0
- package/dist/cards/AnalysisDocumentCard.d.ts.map +1 -0
- package/dist/cards/AnalysisDocumentCard.js +11 -0
- package/dist/cards/AnalysisDocumentCard.js.map +1 -0
- package/dist/cards/AskUserQuestionsCard.d.ts +43 -0
- package/dist/cards/AskUserQuestionsCard.d.ts.map +1 -0
- package/dist/cards/AskUserQuestionsCard.js +171 -0
- package/dist/cards/AskUserQuestionsCard.js.map +1 -0
- package/dist/cards/CoverageReportCard.d.ts +41 -0
- package/dist/cards/CoverageReportCard.d.ts.map +1 -0
- package/dist/cards/CoverageReportCard.js +55 -0
- package/dist/cards/CoverageReportCard.js.map +1 -0
- package/dist/cards/OrchestrationPlanCard.d.ts +60 -0
- package/dist/cards/OrchestrationPlanCard.d.ts.map +1 -0
- package/dist/cards/OrchestrationPlanCard.js +103 -0
- package/dist/cards/OrchestrationPlanCard.js.map +1 -0
- package/dist/cards/PlanSelectorCard.d.ts +40 -0
- package/dist/cards/PlanSelectorCard.d.ts.map +1 -0
- package/dist/cards/PlanSelectorCard.js +106 -0
- package/dist/cards/PlanSelectorCard.js.map +1 -0
- package/dist/cards/PlanStepsCard.d.ts +18 -0
- package/dist/cards/PlanStepsCard.d.ts.map +1 -0
- package/dist/cards/PlanStepsCard.js +25 -0
- package/dist/cards/PlanStepsCard.js.map +1 -0
- package/dist/cards/QuizCard.d.ts +44 -0
- package/dist/cards/QuizCard.d.ts.map +1 -0
- package/dist/cards/QuizCard.js +113 -0
- package/dist/cards/QuizCard.js.map +1 -0
- package/dist/cards/ResearchPlanCard.d.ts +23 -0
- package/dist/cards/ResearchPlanCard.d.ts.map +1 -0
- package/dist/cards/ResearchPlanCard.js +50 -0
- package/dist/cards/ResearchPlanCard.js.map +1 -0
- package/dist/cards/SearchSourcesCard.d.ts +29 -0
- package/dist/cards/SearchSourcesCard.d.ts.map +1 -0
- package/dist/cards/SearchSourcesCard.js +83 -0
- package/dist/cards/SearchSourcesCard.js.map +1 -0
- package/dist/cards/SuggestedRepliesCard.d.ts +18 -0
- package/dist/cards/SuggestedRepliesCard.d.ts.map +1 -0
- package/dist/cards/SuggestedRepliesCard.js +14 -0
- package/dist/cards/SuggestedRepliesCard.js.map +1 -0
- package/dist/cards/SummaryMessageCard.d.ts +40 -0
- package/dist/cards/SummaryMessageCard.d.ts.map +1 -0
- package/dist/cards/SummaryMessageCard.js +56 -0
- package/dist/cards/SummaryMessageCard.js.map +1 -0
- package/dist/cards/ThinkingProcessCard.d.ts +46 -0
- package/dist/cards/ThinkingProcessCard.d.ts.map +1 -0
- package/dist/cards/ThinkingProcessCard.js +79 -0
- package/dist/cards/ThinkingProcessCard.js.map +1 -0
- package/dist/cards/ToolExecutionCard.d.ts +21 -0
- package/dist/cards/ToolExecutionCard.d.ts.map +1 -0
- package/dist/cards/ToolExecutionCard.js +65 -0
- package/dist/cards/ToolExecutionCard.js.map +1 -0
- package/dist/cards/icons.d.ts +38 -0
- package/dist/cards/icons.d.ts.map +1 -0
- package/dist/cards/icons.js +87 -0
- package/dist/cards/icons.js.map +1 -0
- package/dist/cards/index.d.ts +38 -0
- package/dist/cards/index.d.ts.map +1 -0
- package/dist/cards/index.js +24 -0
- package/dist/cards/index.js.map +1 -0
- package/dist/cards/types.d.ts +24 -0
- package/dist/cards/types.d.ts.map +1 -0
- package/dist/cards/types.js +7 -0
- package/dist/cards/types.js.map +1 -0
- package/dist/components/AgentSelector.d.ts +23 -0
- package/dist/components/AgentSelector.d.ts.map +1 -0
- package/dist/components/AgentSelector.js +63 -0
- package/dist/components/AgentSelector.js.map +1 -0
- package/dist/components/AgentSelector.stories.d.ts +12 -0
- package/dist/components/AgentSelector.stories.d.ts.map +1 -0
- package/dist/components/AgentSelector.stories.js +95 -0
- package/dist/components/AgentSelector.stories.js.map +1 -0
- package/dist/components/ChatPanel.d.ts +138 -21
- package/dist/components/ChatPanel.d.ts.map +1 -1
- package/dist/components/ChatPanel.js +181 -26
- package/dist/components/ChatPanel.js.map +1 -1
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/state/ChatSessionProvider.d.ts +59 -0
- package/dist/state/ChatSessionProvider.d.ts.map +1 -0
- package/dist/state/ChatSessionProvider.js +71 -0
- package/dist/state/ChatSessionProvider.js.map +1 -0
- package/dist/state/MockChatStreamTransport.d.ts +54 -0
- package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
- package/dist/state/MockChatStreamTransport.js +86 -0
- package/dist/state/MockChatStreamTransport.js.map +1 -0
- package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
- package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
- package/dist/state/MockChatStreamTransport.stories.js +60 -0
- package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
- package/dist/state/bridgeLegacySSE.d.ts +42 -0
- package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
- package/dist/state/bridgeLegacySSE.js +102 -0
- package/dist/state/bridgeLegacySSE.js.map +1 -0
- package/dist/state/index.d.ts +32 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +24 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/parseSSE.d.ts +50 -0
- package/dist/state/parseSSE.d.ts.map +1 -0
- package/dist/state/parseSSE.js +112 -0
- package/dist/state/parseSSE.js.map +1 -0
- package/dist/state/useChatComposer.d.ts +51 -0
- package/dist/state/useChatComposer.d.ts.map +1 -0
- package/dist/state/useChatComposer.js +82 -0
- package/dist/state/useChatComposer.js.map +1 -0
- package/dist/state/useChatList.d.ts +71 -0
- package/dist/state/useChatList.d.ts.map +1 -0
- package/dist/state/useChatList.js +158 -0
- package/dist/state/useChatList.js.map +1 -0
- package/dist/state/useChatSession.d.ts +29 -0
- package/dist/state/useChatSession.d.ts.map +1 -0
- package/dist/state/useChatSession.js +37 -0
- package/dist/state/useChatSession.js.map +1 -0
- package/dist/state/useScrollLock.d.ts +35 -0
- package/dist/state/useScrollLock.d.ts.map +1 -0
- package/dist/state/useScrollLock.js +74 -0
- package/dist/state/useScrollLock.js.map +1 -0
- package/dist/state/useToolCallStream.d.ts +38 -0
- package/dist/state/useToolCallStream.d.ts.map +1 -0
- package/dist/state/useToolCallStream.js +74 -0
- package/dist/state/useToolCallStream.js.map +1 -0
- package/package.json +10 -2
- package/src/cards/ActionSegmentCard.tsx +60 -0
- package/src/cards/AnalysisDocumentCard.tsx +81 -0
- package/src/cards/AskUserQuestionsCard.tsx +354 -0
- package/src/cards/CoverageReportCard.tsx +215 -0
- package/src/cards/OrchestrationPlanCard.tsx +328 -0
- package/src/cards/PlanSelectorCard.tsx +312 -0
- package/src/cards/PlanStepsCard.tsx +52 -0
- package/src/cards/QuizCard.tsx +309 -0
- package/src/cards/ResearchPlanCard.tsx +169 -0
- package/src/cards/SearchSourcesCard.tsx +244 -0
- package/src/cards/SuggestedRepliesCard.tsx +48 -0
- package/src/cards/SummaryMessageCard.tsx +145 -0
- package/src/cards/ThinkingProcessCard.tsx +155 -0
- package/src/cards/ToolExecutionCard.tsx +152 -0
- package/src/cards/cards.test.tsx +492 -0
- package/src/cards/icons.tsx +249 -0
- package/src/cards/index.ts +53 -0
- package/src/cards/types.ts +27 -0
- package/src/components/AgentSelector.stories.tsx +113 -0
- package/src/components/AgentSelector.test.tsx +70 -0
- package/src/components/AgentSelector.tsx +147 -0
- package/src/components/ChatPanel.test.tsx +130 -0
- package/src/components/ChatPanel.tsx +612 -138
- package/src/components/index.ts +13 -1
- package/src/index.ts +43 -0
- package/src/state/ChatSessionProvider.test.tsx +85 -0
- package/src/state/ChatSessionProvider.tsx +98 -0
- package/src/state/MockChatStreamTransport.stories.tsx +95 -0
- package/src/state/MockChatStreamTransport.test.ts +163 -0
- package/src/state/MockChatStreamTransport.ts +123 -0
- package/src/state/bridgeLegacySSE.test.ts +134 -0
- package/src/state/bridgeLegacySSE.ts +134 -0
- package/src/state/index.ts +82 -0
- package/src/state/parseSSE.test.ts +123 -0
- package/src/state/parseSSE.ts +123 -0
- package/src/state/useChatComposer.test.ts +145 -0
- package/src/state/useChatComposer.ts +117 -0
- package/src/state/useChatList.test.ts +135 -0
- package/src/state/useChatList.ts +217 -0
- package/src/state/useChatSession.test.ts +61 -0
- package/src/state/useChatSession.ts +64 -0
- package/src/state/useScrollLock.test.ts +99 -0
- package/src/state/useScrollLock.ts +105 -0
- package/src/state/useToolCallStream.test.ts +86 -0
- package/src/state/useToolCallStream.ts +110 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useChatSession` — convenience wrapper that combines `useChatStream` (turn
|
|
3
|
+
* lifecycle) with `useChatComposer` (draft + keys + send). Most consumers want
|
|
4
|
+
* this single hook so the wiring is identical across web-shell, deeppath, and
|
|
5
|
+
* deeppath-agent. Power users still reach for the underlying primitives.
|
|
6
|
+
*
|
|
7
|
+
* const { messages, isStreaming, composer, cancel } = useChatSession({
|
|
8
|
+
* transport,
|
|
9
|
+
* initialMessages,
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* <ChatPanel>
|
|
13
|
+
* <MessageList messages={messages} isStreaming={isStreaming} />
|
|
14
|
+
* <ChatInput composer={composer} onCancel={cancel} />
|
|
15
|
+
* </ChatPanel>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useMemo } from 'react';
|
|
19
|
+
import {
|
|
20
|
+
useChatStream,
|
|
21
|
+
type UseChatStreamOptions,
|
|
22
|
+
type UseChatStreamReturn,
|
|
23
|
+
} from '../hooks/useChatStream.js';
|
|
24
|
+
import {
|
|
25
|
+
useChatComposer,
|
|
26
|
+
type UseChatComposerOptions,
|
|
27
|
+
type UseChatComposerReturn,
|
|
28
|
+
} from './useChatComposer.js';
|
|
29
|
+
|
|
30
|
+
export interface UseChatSessionOptions
|
|
31
|
+
extends UseChatStreamOptions,
|
|
32
|
+
Pick<UseChatComposerOptions, 'enterToSubmit' | 'initialValue'> {
|
|
33
|
+
/** Optional metadata callback when submitting a message (e.g. @mentions). */
|
|
34
|
+
buildMetadata?: (value: string) => Record<string, unknown> | undefined;
|
|
35
|
+
/** Force-disable the composer regardless of streaming state. */
|
|
36
|
+
composerDisabled?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UseChatSessionReturn extends UseChatStreamReturn {
|
|
40
|
+
composer: UseChatComposerReturn;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useChatSession(options: UseChatSessionOptions): UseChatSessionReturn {
|
|
44
|
+
const stream = useChatStream(options);
|
|
45
|
+
|
|
46
|
+
const composer = useChatComposer({
|
|
47
|
+
initialValue: options.initialValue,
|
|
48
|
+
enterToSubmit: options.enterToSubmit,
|
|
49
|
+
isStreaming: stream.isStreaming,
|
|
50
|
+
disabled: options.composerDisabled,
|
|
51
|
+
onSend: async (value) => {
|
|
52
|
+
await stream.sendUserMessage({
|
|
53
|
+
content: value,
|
|
54
|
+
metadata: options.buildMetadata?.(value),
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
onCancel: stream.cancel,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return useMemo(
|
|
61
|
+
() => ({ ...stream, composer }),
|
|
62
|
+
[stream, composer],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `useScrollLock`. happy-dom doesn't compute scroll distance
|
|
3
|
+
* automatically, so we manually mutate `scrollTop` and dispatch scroll events.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { act, renderHook } from '@testing-library/react';
|
|
8
|
+
import { useScrollLock } from './useScrollLock';
|
|
9
|
+
|
|
10
|
+
function makeScrollContainer(
|
|
11
|
+
options: { startAtBottom?: boolean } = {},
|
|
12
|
+
): HTMLDivElement {
|
|
13
|
+
const el = document.createElement('div');
|
|
14
|
+
Object.defineProperty(el, 'scrollHeight', { value: 1000, writable: true });
|
|
15
|
+
Object.defineProperty(el, 'clientHeight', { value: 400, writable: true });
|
|
16
|
+
// happy-dom doesn't implement scrollTo; emulate the clamping browsers do.
|
|
17
|
+
el.scrollTo = ((opts: ScrollToOptions) => {
|
|
18
|
+
if (typeof opts === 'object' && typeof opts.top === 'number') {
|
|
19
|
+
const max = el.scrollHeight - el.clientHeight;
|
|
20
|
+
el.scrollTop = Math.min(Math.max(0, opts.top), max);
|
|
21
|
+
}
|
|
22
|
+
}) as typeof el.scrollTo;
|
|
23
|
+
if (options.startAtBottom !== false) {
|
|
24
|
+
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
25
|
+
}
|
|
26
|
+
return el;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useScrollLock', () => {
|
|
30
|
+
it('auto-scrolls to bottom when revalidate keys change and user is anchored', () => {
|
|
31
|
+
let keys: ReadonlyArray<unknown> = [0];
|
|
32
|
+
const { result, rerender } = renderHook(() =>
|
|
33
|
+
useScrollLock({ revalidateKeys: keys }),
|
|
34
|
+
);
|
|
35
|
+
const el = makeScrollContainer();
|
|
36
|
+
act(() => result.current.setRef(el));
|
|
37
|
+
expect(result.current.isAnchored).toBe(true);
|
|
38
|
+
|
|
39
|
+
keys = [1];
|
|
40
|
+
rerender();
|
|
41
|
+
expect(el.scrollTop).toBe(el.scrollHeight - el.clientHeight);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('releases the lock when the user scrolls up past the threshold', () => {
|
|
45
|
+
const { result } = renderHook(() => useScrollLock({ threshold: 24 }));
|
|
46
|
+
const el = makeScrollContainer();
|
|
47
|
+
act(() => result.current.setRef(el));
|
|
48
|
+
expect(result.current.isAnchored).toBe(true);
|
|
49
|
+
|
|
50
|
+
el.scrollTop = 0;
|
|
51
|
+
act(() => {
|
|
52
|
+
el.dispatchEvent(new Event('scroll'));
|
|
53
|
+
});
|
|
54
|
+
expect(result.current.isAnchored).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('re-anchors once the user scrolls back to the bottom', () => {
|
|
58
|
+
const { result } = renderHook(() => useScrollLock({ threshold: 24 }));
|
|
59
|
+
const el = makeScrollContainer();
|
|
60
|
+
act(() => result.current.setRef(el));
|
|
61
|
+
|
|
62
|
+
el.scrollTop = 0;
|
|
63
|
+
act(() => el.dispatchEvent(new Event('scroll')));
|
|
64
|
+
expect(result.current.isAnchored).toBe(false);
|
|
65
|
+
|
|
66
|
+
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
67
|
+
act(() => el.dispatchEvent(new Event('scroll')));
|
|
68
|
+
expect(result.current.isAnchored).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('scrollToBottom is callable imperatively', () => {
|
|
72
|
+
const { result } = renderHook(() => useScrollLock());
|
|
73
|
+
const el = makeScrollContainer({ startAtBottom: false });
|
|
74
|
+
act(() => result.current.setRef(el));
|
|
75
|
+
expect(el.scrollTop).toBe(0);
|
|
76
|
+
|
|
77
|
+
act(() => result.current.scrollToBottom());
|
|
78
|
+
|
|
79
|
+
expect(el.scrollTop).toBe(el.scrollHeight - el.clientHeight);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not auto-scroll when the user is unanchored', () => {
|
|
83
|
+
let keys: ReadonlyArray<unknown> = [0];
|
|
84
|
+
const { result, rerender } = renderHook(() =>
|
|
85
|
+
useScrollLock({ revalidateKeys: keys, threshold: 24 }),
|
|
86
|
+
);
|
|
87
|
+
const el = makeScrollContainer();
|
|
88
|
+
act(() => result.current.setRef(el));
|
|
89
|
+
|
|
90
|
+
el.scrollTop = 0;
|
|
91
|
+
act(() => el.dispatchEvent(new Event('scroll')));
|
|
92
|
+
expect(result.current.isAnchored).toBe(false);
|
|
93
|
+
|
|
94
|
+
keys = [1];
|
|
95
|
+
rerender();
|
|
96
|
+
// Lock released → no auto-scroll, scrollTop stays at user's position.
|
|
97
|
+
expect(el.scrollTop).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useScrollLock` — sticky-bottom scrolling for streaming message lists.
|
|
3
|
+
*
|
|
4
|
+
* The hook returns a callback ref to attach to the scroll container and a
|
|
5
|
+
* `scrollToBottom` imperative API. Behaviour:
|
|
6
|
+
* - When the container's scroll position is within `threshold` px of the
|
|
7
|
+
* bottom, the hook treats the user as "anchored" and re-runs
|
|
8
|
+
* `scrollToBottom` whenever the dependency array `revalidateKeys` changes.
|
|
9
|
+
* - As soon as the user scrolls up past `threshold`, the lock releases —
|
|
10
|
+
* the list will no longer auto-scroll until the user scrolls back to the
|
|
11
|
+
* bottom (or `scrollToBottom` is called explicitly, e.g. on send).
|
|
12
|
+
*
|
|
13
|
+
* Uses a callback ref so the listener attaches the moment the element mounts,
|
|
14
|
+
* not after a second effect pass. Matches deeppath's MessageList scroll
|
|
15
|
+
* handling minus the inline MutationObserver+IntersectionObserver soup.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
19
|
+
|
|
20
|
+
export interface UseScrollLockOptions {
|
|
21
|
+
/** Px from the bottom that still counts as "anchored". Default 24. */
|
|
22
|
+
threshold?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Dependencies that, when changed, will trigger an auto-scroll if the user
|
|
25
|
+
* is currently anchored. Pass e.g. `[messages.length, lastMessageContent]`.
|
|
26
|
+
*/
|
|
27
|
+
revalidateKeys?: ReadonlyArray<unknown>;
|
|
28
|
+
/** Scroll smooth vs jump. Defaults to 'auto' (instant). */
|
|
29
|
+
behavior?: ScrollBehavior;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UseScrollLockReturn<E extends HTMLElement = HTMLDivElement> {
|
|
33
|
+
/** Callback ref — pass directly to `ref={...}` on the scroll container. */
|
|
34
|
+
setRef: (el: E | null) => void;
|
|
35
|
+
isAnchored: boolean;
|
|
36
|
+
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function maxScrollTop(el: HTMLElement): number {
|
|
40
|
+
return Math.max(0, el.scrollHeight - el.clientHeight);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useScrollLock<E extends HTMLElement = HTMLDivElement>(
|
|
44
|
+
options: UseScrollLockOptions = {},
|
|
45
|
+
): UseScrollLockReturn<E> {
|
|
46
|
+
const threshold = options.threshold ?? 24;
|
|
47
|
+
const elRef = useRef<E | null>(null);
|
|
48
|
+
const cleanupRef = useRef<(() => void) | null>(null);
|
|
49
|
+
const [isAnchored, setAnchored] = useState(true);
|
|
50
|
+
const anchoredRef = useRef(true);
|
|
51
|
+
anchoredRef.current = isAnchored;
|
|
52
|
+
|
|
53
|
+
const scrollToBottom = useCallback(
|
|
54
|
+
(behavior?: ScrollBehavior) => {
|
|
55
|
+
const el = elRef.current;
|
|
56
|
+
if (!el) return;
|
|
57
|
+
el.scrollTo({
|
|
58
|
+
top: maxScrollTop(el),
|
|
59
|
+
behavior: behavior ?? options.behavior ?? 'auto',
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
[options.behavior],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const setRef = useCallback(
|
|
66
|
+
(el: E | null) => {
|
|
67
|
+
if (cleanupRef.current) {
|
|
68
|
+
cleanupRef.current();
|
|
69
|
+
cleanupRef.current = null;
|
|
70
|
+
}
|
|
71
|
+
elRef.current = el;
|
|
72
|
+
if (!el) return;
|
|
73
|
+
const handler = () => {
|
|
74
|
+
const distance = maxScrollTop(el) - el.scrollTop;
|
|
75
|
+
const anchored = distance <= threshold;
|
|
76
|
+
if (anchored !== anchoredRef.current) {
|
|
77
|
+
anchoredRef.current = anchored;
|
|
78
|
+
setAnchored(anchored);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
el.addEventListener('scroll', handler, { passive: true });
|
|
82
|
+
cleanupRef.current = () => el.removeEventListener('scroll', handler);
|
|
83
|
+
// Run once to seed `isAnchored` from the current position.
|
|
84
|
+
handler();
|
|
85
|
+
},
|
|
86
|
+
[threshold],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
return () => {
|
|
91
|
+
if (cleanupRef.current) {
|
|
92
|
+
cleanupRef.current();
|
|
93
|
+
cleanupRef.current = null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (anchoredRef.current) scrollToBottom();
|
|
100
|
+
// Consumer-provided dependency array + the scroll callback.
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, options.revalidateKeys ?? []);
|
|
103
|
+
|
|
104
|
+
return { setRef, isAnchored, scrollToBottom };
|
|
105
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `useToolCallStream` — pure derivation of in-flight tool-call
|
|
3
|
+
* status from a `ChatMessage`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { renderHook } from '@testing-library/react';
|
|
8
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
9
|
+
import { useToolCallStream } from './useToolCallStream';
|
|
10
|
+
|
|
11
|
+
const baseMsg: ChatMessage = {
|
|
12
|
+
id: 'm1',
|
|
13
|
+
role: 'assistant',
|
|
14
|
+
content: '',
|
|
15
|
+
createdAt: new Date().toISOString(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('useToolCallStream', () => {
|
|
19
|
+
it('returns an empty entry list when the message has no tool calls', () => {
|
|
20
|
+
const { result } = renderHook(() => useToolCallStream({ message: baseMsg }));
|
|
21
|
+
expect(result.current.entries).toEqual([]);
|
|
22
|
+
expect(result.current.pendingCount).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('classifies pending calls (no result yet) as pending', () => {
|
|
26
|
+
const msg: ChatMessage = {
|
|
27
|
+
...baseMsg,
|
|
28
|
+
toolCalls: [{ id: 'c1', name: 'get_weather', arguments: {} }],
|
|
29
|
+
};
|
|
30
|
+
const { result } = renderHook(() => useToolCallStream({ message: msg }));
|
|
31
|
+
expect(result.current.entries[0].status).toBe('pending');
|
|
32
|
+
expect(result.current.pendingCount).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('infers mode from the tool name', () => {
|
|
36
|
+
const msg: ChatMessage = {
|
|
37
|
+
...baseMsg,
|
|
38
|
+
toolCalls: [
|
|
39
|
+
{ id: 'r', name: 'get_user', arguments: {} },
|
|
40
|
+
{ id: 'd', name: 'delete_user', arguments: {} },
|
|
41
|
+
{ id: 'l', name: 'local_exec', arguments: {} },
|
|
42
|
+
{ id: 'w', name: 'create_thing', arguments: {} },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
const { result } = renderHook(() => useToolCallStream({ message: msg }));
|
|
46
|
+
expect(result.current.entries.map((e) => e.mode)).toEqual([
|
|
47
|
+
'read',
|
|
48
|
+
'destructive',
|
|
49
|
+
'local',
|
|
50
|
+
'safe_write',
|
|
51
|
+
]);
|
|
52
|
+
expect(result.current.entries[1].isDestructive).toBe(true);
|
|
53
|
+
expect(result.current.entries[2].requiresApproval).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('overrides mode via modeByName', () => {
|
|
57
|
+
const msg: ChatMessage = {
|
|
58
|
+
...baseMsg,
|
|
59
|
+
toolCalls: [{ id: 'c1', name: 'mystery', arguments: {} }],
|
|
60
|
+
};
|
|
61
|
+
const { result } = renderHook(() =>
|
|
62
|
+
useToolCallStream({ message: msg, modeByName: { mystery: 'destructive' } }),
|
|
63
|
+
);
|
|
64
|
+
expect(result.current.entries[0].mode).toBe('destructive');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('pairs results via resultByCallId, falls back to message.toolResult', () => {
|
|
68
|
+
const msg: ChatMessage = {
|
|
69
|
+
...baseMsg,
|
|
70
|
+
toolCalls: [
|
|
71
|
+
{ id: 'a', name: 'get_a', arguments: {} },
|
|
72
|
+
{ id: 'b', name: 'get_b', arguments: {} },
|
|
73
|
+
],
|
|
74
|
+
toolResult: { success: true, data: { fallback: true } },
|
|
75
|
+
};
|
|
76
|
+
const { result } = renderHook(() =>
|
|
77
|
+
useToolCallStream({
|
|
78
|
+
message: msg,
|
|
79
|
+
resultByCallId: { a: { success: false, data: { err: 'x' } } },
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
expect(result.current.entries[0].status).toBe('error');
|
|
83
|
+
expect(result.current.entries[1].status).toBe('done');
|
|
84
|
+
expect(result.current.errorCount).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useToolCallStream` — turn the assistant message's `toolCalls` / `toolResult`
|
|
3
|
+
* (as maintained by `useChatStream`) into a derived `{ id -> {call, result,
|
|
4
|
+
* status, mode} }` map, so renderers can show per-call cards without
|
|
5
|
+
* threading status through the tree manually.
|
|
6
|
+
*
|
|
7
|
+
* Aggregates `useToolCallStatus` across all tool calls on a single message.
|
|
8
|
+
* Pure derivation, no I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useMemo } from 'react';
|
|
12
|
+
import type { ChatMessage, ToolCall, ToolResult } from '@steerable/agent-protocol';
|
|
13
|
+
import {
|
|
14
|
+
useToolCallStatus,
|
|
15
|
+
type ToolCallMode,
|
|
16
|
+
type ToolCallStatus,
|
|
17
|
+
} from '../hooks/useToolCallStatus.js';
|
|
18
|
+
|
|
19
|
+
export interface ToolCallEntry {
|
|
20
|
+
call: ToolCall;
|
|
21
|
+
result?: ToolResult;
|
|
22
|
+
status: ToolCallStatus;
|
|
23
|
+
mode: ToolCallMode;
|
|
24
|
+
isDestructive: boolean;
|
|
25
|
+
requiresApproval: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseToolCallStreamOptions {
|
|
29
|
+
message: ChatMessage | null | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Optional per-call result lookup — when the runtime emits `tool_result`
|
|
32
|
+
* with a `callId`, we can pair it to the originating call rather than
|
|
33
|
+
* relying on `message.toolResult` (which always holds the last result).
|
|
34
|
+
*/
|
|
35
|
+
resultByCallId?: Record<string, ToolResult>;
|
|
36
|
+
/** Optional mode override per tool name. */
|
|
37
|
+
modeByName?: Record<string, ToolCallMode>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UseToolCallStreamReturn {
|
|
41
|
+
entries: ToolCallEntry[];
|
|
42
|
+
pendingCount: number;
|
|
43
|
+
errorCount: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useToolCallStream(
|
|
47
|
+
options: UseToolCallStreamOptions,
|
|
48
|
+
): UseToolCallStreamReturn {
|
|
49
|
+
const message = options.message ?? null;
|
|
50
|
+
return useMemo(() => {
|
|
51
|
+
if (!message || !Array.isArray(message.toolCalls)) {
|
|
52
|
+
return { entries: [], pendingCount: 0, errorCount: 0 };
|
|
53
|
+
}
|
|
54
|
+
const entries: ToolCallEntry[] = message.toolCalls.map((call) => {
|
|
55
|
+
const lookup = options.resultByCallId?.[call.id];
|
|
56
|
+
const fallback =
|
|
57
|
+
message.toolResult && (message.toolResult as { callId?: string }).callId === call.id
|
|
58
|
+
? message.toolResult
|
|
59
|
+
: undefined;
|
|
60
|
+
const result = lookup ?? fallback ?? message.toolResult;
|
|
61
|
+
const status = deriveStatus(result);
|
|
62
|
+
const mode = options.modeByName?.[call.name] ?? inferMode(call.name);
|
|
63
|
+
return {
|
|
64
|
+
call,
|
|
65
|
+
result,
|
|
66
|
+
status,
|
|
67
|
+
mode,
|
|
68
|
+
isDestructive: mode === 'destructive',
|
|
69
|
+
requiresApproval: mode === 'local',
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
entries,
|
|
74
|
+
pendingCount: entries.filter((e) => e.status === 'pending').length,
|
|
75
|
+
errorCount: entries.filter((e) => e.status === 'error').length,
|
|
76
|
+
};
|
|
77
|
+
}, [message, options.resultByCallId, options.modeByName]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `useToolCallStatus` is per-call; this hook is the bulk variant. We keep the
|
|
81
|
+
// inference rules in sync by re-using the same heuristics here.
|
|
82
|
+
|
|
83
|
+
const READ_PATTERNS = [/^get[_-]/, /^list[_-]/, /^read[_-]/, /^search[_-]/];
|
|
84
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
85
|
+
/^delete[_-]/,
|
|
86
|
+
/^remove[_-]/,
|
|
87
|
+
/^archive[_-]/,
|
|
88
|
+
/^purge[_-]/,
|
|
89
|
+
/^drop[_-]/,
|
|
90
|
+
];
|
|
91
|
+
const SAFE_WRITE_PATTERNS = [/^create[_-]/, /^update[_-]/, /^add[_-]/, /^set[_-]/];
|
|
92
|
+
const LOCAL_PATTERNS = [/^local[_-]/, /^shell[_-]/, /^exec[_-]/];
|
|
93
|
+
|
|
94
|
+
function inferMode(name: string): ToolCallMode {
|
|
95
|
+
const lower = name.toLowerCase();
|
|
96
|
+
if (READ_PATTERNS.some((re) => re.test(lower))) return 'read';
|
|
97
|
+
if (LOCAL_PATTERNS.some((re) => re.test(lower))) return 'local';
|
|
98
|
+
if (DESTRUCTIVE_PATTERNS.some((re) => re.test(lower))) return 'destructive';
|
|
99
|
+
if (SAFE_WRITE_PATTERNS.some((re) => re.test(lower))) return 'safe_write';
|
|
100
|
+
return 'unknown';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function deriveStatus(result: ToolResult | undefined): ToolCallStatus {
|
|
104
|
+
if (!result) return 'pending';
|
|
105
|
+
if (result.success === false) return 'error';
|
|
106
|
+
return 'done';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Re-export the per-call hook for convenience.
|
|
110
|
+
export { useToolCallStatus } from '../hooks/useToolCallStatus.js';
|