@steerable/agent-ui 0.6.57 → 0.6.59

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.
@@ -59,11 +59,11 @@ export const ToolExecutionCard = ({ payload, className, defaultExpanded = false,
59
59
  return (_jsxs("div", { className: [
60
60
  'steerable-tool-execution rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-xs',
61
61
  className,
62
- ].filter(Boolean).join(' '), children: [_jsxs("div", { className: "flex w-full items-stretch", children: [expandable && (_jsx("button", { type: "button", onClick: () => setExpanded((v) => !v), className: "flex shrink-0 items-center px-2.5 py-1 text-[var(--agent-muted-foreground,#6b7280)]", "aria-expanded": expanded, "aria-label": expanded ? '收起详情' : '展开详情', children: expanded ? _jsx(ChevronDownIcon, { size: 12 }) : _jsx(ChevronRightIcon, { size: 12 }) })), _jsxs("button", { type: "button", onClick: () => {
62
+ ].filter(Boolean).join(' '), children: [_jsxs("div", { className: "flex w-full items-stretch", children: [expandable && (_jsx("button", { type: "button", onClick: () => setExpanded((v) => !v), className: "flex shrink-0 items-center text-[var(--agent-muted-foreground,#6b7280)]", "aria-expanded": expanded, "aria-label": expanded ? '收起详情' : '展开详情', children: _jsx("span", { className: "flex items-center px-2.5 py-1", children: expanded ? _jsx(ChevronDownIcon, { size: 12 }) : _jsx(ChevronRightIcon, { size: 12 }) }) })), _jsx("button", { type: "button", onClick: () => {
63
63
  if (onActivate)
64
64
  onActivate();
65
65
  else if (expandable)
66
66
  setExpanded((v) => !v);
67
- }, className: `flex min-w-0 flex-1 items-center gap-1.5 py-1 pr-2.5 text-left ${expandable ? '' : 'pl-2.5'} ${onActivate ? 'cursor-pointer hover:bg-[var(--agent-muted,#f3f4f6)]/60' : ''}`, "aria-expanded": onActivate ? undefined : expanded, "aria-label": onActivate ? activateTitle : undefined, title: onActivate ? activateTitle : undefined, "data-testid": onActivate ? 'tool-activate' : undefined, children: [lead ?? (_jsx(ZapIcon, { size: 12, className: "shrink-0 text-[var(--agent-muted-foreground,#6b7280)]" })), _jsx("span", { className: "shrink-0 font-medium text-[var(--agent-foreground,#111827)]", children: title }), payload.summary && (_jsx("span", { className: "min-w-0 flex-1 truncate text-[var(--agent-muted-foreground,#6b7280)]", children: payload.summary })), _jsxs("span", { className: "ml-auto inline-flex shrink-0 items-center gap-1 whitespace-nowrap", children: [_jsx(StatusIcon, { status: status }), _jsx("span", { className: STATUS_TONE[status], children: STATUS_LABEL[status] }), typeof payload.durationMs === 'number' && (_jsxs("span", { className: "text-[var(--agent-muted-foreground,#9ca3af)]", children: ["\u00B7 ", payload.durationMs, "ms"] }))] })] })] }), expandable && expanded && (_jsxs("div", { className: "space-y-1.5 border-t border-[var(--agent-border,#e5e7eb)] px-2.5 py-1.5", children: [payload.args !== undefined && (_jsxs("section", { children: [_jsx("div", { className: "mb-1 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#6b7280)]", children: "\u8F93\u5165" }), renderArgs ? renderArgs(payload.args) : defaultRender(payload.args)] })), payload.output !== undefined && (_jsxs("section", { children: [_jsx("div", { className: "mb-1 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#6b7280)]", children: "\u8F93\u51FA" }), renderOutput ? renderOutput(payload.output) : defaultRender(payload.output)] })), payload.error && (_jsxs("section", { children: [_jsx("div", { className: "mb-1 text-[10px] uppercase tracking-wider text-rose-600", children: "\u9519\u8BEF" }), _jsx("pre", { className: "whitespace-pre-wrap break-words rounded bg-rose-50/60 px-2 py-1.5 text-[11px] text-rose-700", children: payload.error })] }))] }))] }));
67
+ }, className: `flex min-w-0 flex-1 text-left ${onActivate ? 'cursor-pointer hover:bg-[var(--agent-muted,#f3f4f6)]/60' : ''}`, "aria-expanded": onActivate ? undefined : expanded, "aria-label": onActivate ? activateTitle : undefined, title: onActivate ? activateTitle : undefined, "data-testid": onActivate ? 'tool-activate' : undefined, children: _jsxs("span", { className: `flex min-w-0 flex-1 items-center gap-1.5 py-1 pr-2.5 ${expandable ? '' : 'pl-2.5'}`, children: [lead ?? (_jsx(ZapIcon, { size: 12, className: "shrink-0 text-[var(--agent-muted-foreground,#6b7280)]" })), _jsx("span", { className: "shrink-0 font-medium text-[var(--agent-foreground,#111827)]", children: title }), payload.summary && (_jsx("span", { className: "min-w-0 flex-1 truncate text-[var(--agent-muted-foreground,#6b7280)]", children: payload.summary })), _jsxs("span", { className: "ml-auto inline-flex shrink-0 items-center gap-1 whitespace-nowrap", children: [_jsx(StatusIcon, { status: status }), _jsx("span", { className: STATUS_TONE[status], children: STATUS_LABEL[status] }), typeof payload.durationMs === 'number' && (_jsxs("span", { className: "text-[var(--agent-muted-foreground,#9ca3af)]", children: ["\u00B7 ", payload.durationMs, "ms"] }))] })] }) })] }), expandable && expanded && (_jsxs("div", { className: "space-y-1.5 border-t border-[var(--agent-border,#e5e7eb)] px-2.5 py-1.5", children: [payload.args !== undefined && (_jsxs("section", { children: [_jsx("div", { className: "mb-1 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#6b7280)]", children: "\u8F93\u5165" }), renderArgs ? renderArgs(payload.args) : defaultRender(payload.args)] })), payload.output !== undefined && (_jsxs("section", { children: [_jsx("div", { className: "mb-1 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#6b7280)]", children: "\u8F93\u51FA" }), renderOutput ? renderOutput(payload.output) : defaultRender(payload.output)] })), payload.error && (_jsxs("section", { children: [_jsx("div", { className: "mb-1 text-[10px] uppercase tracking-wider text-rose-600", children: "\u9519\u8BEF" }), _jsx("pre", { className: "whitespace-pre-wrap break-words rounded bg-rose-50/60 px-2 py-1.5 text-[11px] text-rose-700", children: payload.error })] }))] }))] }));
68
68
  };
69
69
  export default ToolExecutionCard;
@@ -30,6 +30,8 @@ export function useChatList(options) {
30
30
  const [error, setError] = useState(null);
31
31
  const [selectedAgentId, setSelectedAgentId] = useState(null);
32
32
  const pageRef = useRef(1);
33
+ const hasMoreRef = useRef(false);
34
+ const loadingMoreRef = useRef(false);
33
35
  const mountedRef = useRef(true);
34
36
  useEffect(() => {
35
37
  mountedRef.current = true;
@@ -45,6 +47,7 @@ export function useChatList(options) {
45
47
  return;
46
48
  setChats(res.chats);
47
49
  setHasMore(res.hasMore);
50
+ hasMoreRef.current = res.hasMore;
48
51
  pageRef.current = 1;
49
52
  setError(null);
50
53
  }
@@ -69,16 +72,27 @@ export function useChatList(options) {
69
72
  }
70
73
  }, []);
71
74
  const loadMoreChats = useCallback(async () => {
72
- if (!hasMoreChats || isLoadingMoreChats)
75
+ if (!hasMoreRef.current || loadingMoreRef.current)
73
76
  return;
77
+ loadingMoreRef.current = true;
74
78
  setLoadingMore(true);
75
79
  try {
76
80
  const next = pageRef.current + 1;
77
81
  const res = await transportRef.current.listChats({ page: next, pageSize });
78
82
  if (!mountedRef.current)
79
83
  return;
80
- setChats((prev) => prev.concat(res.chats));
84
+ setChats((prev) => {
85
+ const ids = new Set(prev.map(transportRef.current.getChatId));
86
+ return prev.concat(res.chats.filter((chat) => {
87
+ const id = transportRef.current.getChatId(chat);
88
+ if (ids.has(id))
89
+ return false;
90
+ ids.add(id);
91
+ return true;
92
+ }));
93
+ });
81
94
  setHasMore(res.hasMore);
95
+ hasMoreRef.current = res.hasMore;
82
96
  pageRef.current = next;
83
97
  }
84
98
  catch (err) {
@@ -86,10 +100,11 @@ export function useChatList(options) {
86
100
  setError(err instanceof Error ? err : new Error(String(err)));
87
101
  }
88
102
  finally {
103
+ loadingMoreRef.current = false;
89
104
  if (mountedRef.current)
90
105
  setLoadingMore(false);
91
106
  }
92
- }, [hasMoreChats, isLoadingMoreChats, pageSize]);
107
+ }, [pageSize]);
93
108
  const createChat = useCallback(async (input) => {
94
109
  const t = transportRef.current;
95
110
  if (!t.createChat)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-ui",
3
- "version": "0.6.57",
3
+ "version": "0.6.59",
4
4
  "description": "Steerable framework Tier 4 — headless React hooks (useAgentSession, useChatStream, useToolCallStatus) and Tailwind-themed components (ChatPanel, MessageList, OrchestrationPlanCard, ToolCallRenderer, SSEStreamView) for embedding Steerable agents in any React app.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/storybook/",
@@ -57,7 +57,7 @@
57
57
  "README.md"
58
58
  ],
59
59
  "dependencies": {
60
- "@steerable/agent-protocol": "0.6.57"
60
+ "@steerable/agent-protocol": "0.6.59"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "react": ">=18.0.0",
@@ -112,11 +112,14 @@ export const ToolExecutionCard: React.FC<ToolExecutionCardProps> = ({
112
112
  <button
113
113
  type="button"
114
114
  onClick={() => setExpanded((v) => !v)}
115
- className="flex shrink-0 items-center px-2.5 py-1 text-[var(--agent-muted-foreground,#6b7280)]"
115
+ className="flex shrink-0 items-center text-[var(--agent-muted-foreground,#6b7280)]"
116
116
  aria-expanded={expanded}
117
117
  aria-label={expanded ? '收起详情' : '展开详情'}
118
118
  >
119
- {expanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />}
119
+ {/* WKWebView ignores horizontal padding on a flex button. */}
120
+ <span className="flex items-center px-2.5 py-1">
121
+ {expanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />}
122
+ </span>
120
123
  </button>
121
124
  )}
122
125
  <button
@@ -125,29 +128,31 @@ export const ToolExecutionCard: React.FC<ToolExecutionCardProps> = ({
125
128
  if (onActivate) onActivate();
126
129
  else if (expandable) setExpanded((v) => !v);
127
130
  }}
128
- className={`flex min-w-0 flex-1 items-center gap-1.5 py-1 pr-2.5 text-left ${expandable ? '' : 'pl-2.5'} ${onActivate ? 'cursor-pointer hover:bg-[var(--agent-muted,#f3f4f6)]/60' : ''}`}
131
+ className={`flex min-w-0 flex-1 text-left ${onActivate ? 'cursor-pointer hover:bg-[var(--agent-muted,#f3f4f6)]/60' : ''}`}
129
132
  aria-expanded={onActivate ? undefined : expanded}
130
133
  aria-label={onActivate ? activateTitle : undefined}
131
134
  title={onActivate ? activateTitle : undefined}
132
135
  data-testid={onActivate ? 'tool-activate' : undefined}
133
136
  >
134
- {lead ?? (
135
- <ZapIcon size={12} className="shrink-0 text-[var(--agent-muted-foreground,#6b7280)]" />
136
- )}
137
- <span className="shrink-0 font-medium text-[var(--agent-foreground,#111827)]">
138
- {title}
139
- </span>
140
- {payload.summary && (
141
- <span className="min-w-0 flex-1 truncate text-[var(--agent-muted-foreground,#6b7280)]">
142
- {payload.summary}
137
+ <span className={`flex min-w-0 flex-1 items-center gap-1.5 py-1 pr-2.5 ${expandable ? '' : 'pl-2.5'}`}>
138
+ {lead ?? (
139
+ <ZapIcon size={12} className="shrink-0 text-[var(--agent-muted-foreground,#6b7280)]" />
140
+ )}
141
+ <span className="shrink-0 font-medium text-[var(--agent-foreground,#111827)]">
142
+ {title}
143
143
  </span>
144
- )}
145
- <span className="ml-auto inline-flex shrink-0 items-center gap-1 whitespace-nowrap">
146
- <StatusIcon status={status} />
147
- <span className={STATUS_TONE[status]}>{STATUS_LABEL[status]}</span>
148
- {typeof payload.durationMs === 'number' && (
149
- <span className="text-[var(--agent-muted-foreground,#9ca3af)]">· {payload.durationMs}ms</span>
144
+ {payload.summary && (
145
+ <span className="min-w-0 flex-1 truncate text-[var(--agent-muted-foreground,#6b7280)]">
146
+ {payload.summary}
147
+ </span>
150
148
  )}
149
+ <span className="ml-auto inline-flex shrink-0 items-center gap-1 whitespace-nowrap">
150
+ <StatusIcon status={status} />
151
+ <span className={STATUS_TONE[status]}>{STATUS_LABEL[status]}</span>
152
+ {typeof payload.durationMs === 'number' && (
153
+ <span className="text-[var(--agent-muted-foreground,#9ca3af)]">· {payload.durationMs}ms</span>
154
+ )}
155
+ </span>
151
156
  </span>
152
157
  </button>
153
158
  </div>
@@ -490,6 +490,10 @@ describe('ToolExecutionCard', () => {
490
490
  const label = screen.getByText('已完成');
491
491
  expect(label.parentElement?.className).toMatch(/shrink-0/);
492
492
  expect(label.parentElement?.className).toMatch(/whitespace-nowrap/);
493
+ const row = label.parentElement?.parentElement;
494
+ expect(row?.tagName).toBe('SPAN');
495
+ expect(row?.className).toMatch(/pr-2\.5/);
496
+ expect(label.closest('button')?.className ?? '').not.toMatch(/pr-2\.5/);
493
497
  });
494
498
 
495
499
  it('expands to show args/output and renders error', () => {
@@ -83,6 +83,22 @@ describe('useChatList', () => {
83
83
  expect(result.current.hasMoreChats).toBe(false);
84
84
  });
85
85
 
86
+ it('coalesces concurrent loadMoreChats calls for the same page', async () => {
87
+ const { transport, listChats } = makeTransport();
88
+ const { result } = renderHook(() => useChatList({ transport, pageSize: 50 }));
89
+
90
+ await waitFor(() => expect(result.current.chats.length).toBe(2));
91
+
92
+ await act(async () => {
93
+ const first = result.current.loadMoreChats();
94
+ const second = result.current.loadMoreChats();
95
+ await Promise.all([first, second]);
96
+ });
97
+
98
+ expect(listChats).toHaveBeenCalledTimes(2);
99
+ expect(result.current.chats.map((c) => c.id)).toEqual(['c1', 'c2', 'c3']);
100
+ });
101
+
86
102
  it('patchChatTitle updates without re-fetching', async () => {
87
103
  const { transport, listChats } = makeTransport();
88
104
  const { result } = renderHook(() => useChatList({ transport, pageSize: 50 }));
@@ -88,6 +88,8 @@ export function useChatList<TChat, TAgent>(
88
88
  const [error, setError] = useState<Error | null>(null);
89
89
  const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
90
90
  const pageRef = useRef(1);
91
+ const hasMoreRef = useRef(false);
92
+ const loadingMoreRef = useRef(false);
91
93
  const mountedRef = useRef(true);
92
94
 
93
95
  useEffect(() => {
@@ -104,6 +106,7 @@ export function useChatList<TChat, TAgent>(
104
106
  if (!mountedRef.current) return;
105
107
  setChats(res.chats);
106
108
  setHasMore(res.hasMore);
109
+ hasMoreRef.current = res.hasMore;
107
110
  pageRef.current = 1;
108
111
  setError(null);
109
112
  } catch (err) {
@@ -123,21 +126,34 @@ export function useChatList<TChat, TAgent>(
123
126
  }, []);
124
127
 
125
128
  const loadMoreChats = useCallback(async () => {
126
- if (!hasMoreChats || isLoadingMoreChats) return;
129
+ if (!hasMoreRef.current || loadingMoreRef.current) return;
130
+ loadingMoreRef.current = true;
127
131
  setLoadingMore(true);
128
132
  try {
129
133
  const next = pageRef.current + 1;
130
134
  const res = await transportRef.current.listChats({ page: next, pageSize });
131
135
  if (!mountedRef.current) return;
132
- setChats((prev) => prev.concat(res.chats));
136
+ setChats((prev) => {
137
+ const ids = new Set(prev.map(transportRef.current.getChatId));
138
+ return prev.concat(
139
+ res.chats.filter((chat) => {
140
+ const id = transportRef.current.getChatId(chat);
141
+ if (ids.has(id)) return false;
142
+ ids.add(id);
143
+ return true;
144
+ }),
145
+ );
146
+ });
133
147
  setHasMore(res.hasMore);
148
+ hasMoreRef.current = res.hasMore;
134
149
  pageRef.current = next;
135
150
  } catch (err) {
136
151
  if (mountedRef.current) setError(err instanceof Error ? err : new Error(String(err)));
137
152
  } finally {
153
+ loadingMoreRef.current = false;
138
154
  if (mountedRef.current) setLoadingMore(false);
139
155
  }
140
- }, [hasMoreChats, isLoadingMoreChats, pageSize]);
156
+ }, [pageSize]);
141
157
 
142
158
  const createChat = useCallback(
143
159
  async (input?: { agentId?: string; projectId?: string }) => {