@parallelworks/ui 0.76.0 → 0.77.0

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.
@@ -28,6 +28,7 @@ export interface ToolApprovalAnswer {
28
28
  toolCallId: string;
29
29
  allowed: boolean;
30
30
  feedback?: string;
31
+ tool?: string;
31
32
  }
32
33
  export interface StreamHandlers {
33
34
  onContent: (content: string) => void;
@@ -0,0 +1,2 @@
1
+ /** An agent session's working line for the provider's reply, naming its current step. */
2
+ export default function ChatActivity(): import("react").JSX.Element | null;
@@ -0,0 +1,31 @@
1
+ import { toolHeaderFor } from "../agent/toolfmt.js";
2
+ import { useChatConfig } from "../core/config.js";
3
+ import { useChat, useChatStream } from "../core/ChatProvider.js";
4
+ import ActivityLine from "./agent/ActivityLine.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ import { useEffect, useState } from "react";
7
+ import { DateTime } from "luxon";
8
+ //#region src/ai/components/ChatActivity.tsx
9
+ /** An agent session's working line for the provider's reply, naming its current step. */
10
+ function ChatActivity() {
11
+ const { isStreaming, tools } = useChat();
12
+ const { streamingParts } = useChatStream();
13
+ const { strings } = useChatConfig();
14
+ const [startedAt, setStartedAt] = useState(null);
15
+ useEffect(() => {
16
+ setStartedAt(isStreaming ? DateTime.now().toUTC().toISO() : null);
17
+ }, [isStreaming]);
18
+ if (!isStreaming) return null;
19
+ const waiting = streamingParts.some((p) => p.kind === "approval" && !p.resolved);
20
+ const running = streamingParts.findLast((p) => p.kind === "tool_call" && p.status === "running");
21
+ let word;
22
+ if (waiting) word = strings.activity.waitingForAnswer;
23
+ else if (running?.kind === "tool_call") word = tools.find((t) => t.id === running.name)?.label ?? toolHeaderFor(running.name).label;
24
+ return /* @__PURE__ */ jsx(ActivityLine, {
25
+ testId: "chat-activity",
26
+ startedAt,
27
+ word
28
+ });
29
+ }
30
+ //#endregion
31
+ export { ChatActivity as default };
@@ -4,6 +4,7 @@ import DragOverlay from "./DragOverlay.js";
4
4
  import ChatInput from "./ChatInput.js";
5
5
  import { escapeClosesLayer } from "../core/useKeyboardShortcuts.js";
6
6
  import ChatMessageList from "./ChatMessageList.js";
7
+ import ChatActivity from "./ChatActivity.js";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
8
9
  import { useCallback, useEffect, useRef, useState } from "react";
9
10
  //#region src/ai/components/SessionView.tsx
@@ -36,9 +37,11 @@ function SessionView({ sessionKey, messages, allMessages, streamingMessage, queu
36
37
  inputRef.current?.addFiles(files);
37
38
  }, []);
38
39
  const { isDragging, dragHandlers } = useDragDrop({ onFilesDropped });
40
+ const activity = composer?.activity ?? (!turn && running ? /* @__PURE__ */ jsx(ChatActivity, {}) : void 0);
39
41
  const input = /* @__PURE__ */ jsx(ChatInput, {
40
42
  ref: inputRef,
41
43
  ...composer,
44
+ activity,
42
45
  onSend,
43
46
  conversationId: sessionKey,
44
47
  disabled: loading || !!composer?.disabled,
@@ -87,7 +90,7 @@ function SessionView({ sessionKey, messages, allMessages, streamingMessage, queu
87
90
  ...footer ? { footer } : {},
88
91
  ...label ? { label } : {},
89
92
  ...flush ? { flush } : {},
90
- ...workingIndicator !== void 0 ? { workingIndicator } : {},
93
+ workingIndicator: workingIndicator ?? !activity,
91
94
  inputElement: input
92
95
  }),
93
96
  error && input
@@ -20,7 +20,7 @@ type RenderItem = {
20
20
  kind: 'rollup';
21
21
  members: RunMember[];
22
22
  };
23
- export declare function buildRenderItems(parts: MessagePart[]): RenderItem[];
23
+ export declare function buildRenderItems(parts: MessagePart[], hostDrawn?: (toolName: string) => boolean): RenderItem[];
24
24
  export default function AgentMessageParts({ message, isStreaming, onApprovalAnswer, }: {
25
25
  message: PartRendererProps['message'];
26
26
  isStreaming?: boolean;
@@ -232,7 +232,10 @@ function ToolStatusDot({ part }) {
232
232
  }
233
233
  /** A part's toggle row: its status mark and name, across the transcript's width. */
234
234
  var rowSummaryClasses = "min-h-6 w-full";
235
- function ToolCallBlock({ part }) {
235
+ /** A call's row: its status and signature, opening to its full arguments and
236
+ * result. `children` is a host's drawing of the call, in place of the
237
+ * one-line summary. */
238
+ function ToolCallBlock({ part, children }) {
236
239
  const { strings } = useChatConfig();
237
240
  const t = strings.agentTranscript;
238
241
  const [expanded, setExpanded] = useState(false);
@@ -279,7 +282,11 @@ function ToolCallBlock({ part }) {
279
282
  children: fullToolArgs(header.arg, part.args)
280
283
  }), /* @__PURE__ */ jsx(ResultBody, { part })]
281
284
  }),
282
- !isDiff && !expanded && part.status !== "waiting" && /* @__PURE__ */ jsx("div", {
285
+ children && /* @__PURE__ */ jsx("div", {
286
+ className: "ml-5 mt-1.5 min-w-0",
287
+ children
288
+ }),
289
+ !children && !isDiff && !expanded && part.status !== "waiting" && /* @__PURE__ */ jsx("div", {
283
290
  className: cx("ml-5 mt-0.5 truncate text-[13px]", part.status === "error" ? "text-(--theme-error-text)" : "theme-muted-text"),
284
291
  children: summary
285
292
  }),
@@ -735,6 +742,21 @@ function TextBlock({ part, isStreaming }) {
735
742
  }
736
743
  function PartDispatch(props) {
737
744
  const { slots } = useChatConfig();
745
+ const part = props.part;
746
+ const Tool = part.kind === "tool_call" ? slots.toolRenderers?.[part.name] : void 0;
747
+ if (Tool && part.kind === "tool_call") {
748
+ const approval = props.message.parts?.find((p) => p.kind === "approval" && p.id === part.id);
749
+ return /* @__PURE__ */ jsx(ToolCallBlock, {
750
+ part,
751
+ children: /* @__PURE__ */ jsx(Tool, {
752
+ part,
753
+ message: props.message,
754
+ ...approval ? { approval } : {},
755
+ ...props.isStreaming !== void 0 ? { isStreaming: props.isStreaming } : {},
756
+ ...props.onApprovalAnswer ? { onApprovalAnswer: props.onApprovalAnswer } : {}
757
+ })
758
+ });
759
+ }
738
760
  const Override = slots.partRenderers?.[props.part.kind];
739
761
  if (Override) return /* @__PURE__ */ jsx(Override, { ...props });
740
762
  return /* @__PURE__ */ jsx(BuiltinPart, {
@@ -783,7 +805,7 @@ function mergeReasoning(parts) {
783
805
  ...durationMs > 0 ? { durationMs } : {}
784
806
  };
785
807
  }
786
- function buildRenderItems(parts) {
808
+ function buildRenderItems(parts, hostDrawn = () => false) {
787
809
  const items = [];
788
810
  let run = [];
789
811
  const emitThoughts = (members) => {
@@ -825,9 +847,11 @@ function buildRenderItems(parts) {
825
847
  run = [];
826
848
  };
827
849
  const decided = new Set(parts.flatMap((p) => p.kind === "tool_call" && p.decision ? [p.id] : []));
850
+ const drawnByHost = new Set(parts.flatMap((p) => p.kind === "tool_call" && hostDrawn(p.name) ? [p.id] : []));
828
851
  parts.forEach((part, index) => {
829
852
  if (part.kind === "approval" && part.resolved && decided.has(part.id)) return;
830
- if (part.kind === "reasoning" || part.kind === "tool_call" && part.status === "ok" && !part.decision && isGroupableTool(part.name)) {
853
+ if (part.kind === "approval" && drawnByHost.has(part.id)) return;
854
+ if (part.kind === "reasoning" || part.kind === "tool_call" && part.status === "ok" && !part.decision && !drawnByHost.has(part.id) && isGroupableTool(part.name)) {
831
855
  run.push({
832
856
  part,
833
857
  index
@@ -871,7 +895,9 @@ function RollupGroup({ members, message }) {
871
895
  }
872
896
  function AgentMessageParts({ message, isStreaming, onApprovalAnswer }) {
873
897
  const parts = message.parts ?? [];
874
- const items = useMemo(() => buildRenderItems(parts), [parts]);
898
+ const { slots } = useChatConfig();
899
+ const toolRenderers = slots.toolRenderers;
900
+ const items = useMemo(() => buildRenderItems(parts, (name) => !!toolRenderers?.[name]), [parts, toolRenderers]);
875
901
  const lastIndex = parts.length - 1;
876
902
  return /* @__PURE__ */ jsx("div", {
877
903
  className: "w-full",
@@ -637,6 +637,14 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
637
637
  at: DateTime.now().toUTC().toISO()
638
638
  }
639
639
  });
640
+ const instead = answer.allowed ? answer.instead : void 0;
641
+ if (instead) {
642
+ pausedParts = pausedParts.map((p) => p.kind === "tool_call" && p.id === approvalId ? {
643
+ ...p,
644
+ name: instead
645
+ } : p);
646
+ updatePaused({ parts: pausedParts });
647
+ }
640
648
  streamingToConversationRef.current = targetConversationId;
641
649
  startStreaming(targetConversationId);
642
650
  try {
@@ -652,7 +660,8 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
652
660
  messageId: paused.id,
653
661
  toolCallId: approvalId,
654
662
  allowed: answer.allowed,
655
- ...answer.denyMessage ? { feedback: answer.denyMessage } : {}
663
+ ...answer.denyMessage ? { feedback: answer.denyMessage } : {},
664
+ ...instead ? { tool: instead } : {}
656
665
  }
657
666
  }, paused.id, (message) => updatePaused({ error: message }), (delta) => {
658
667
  if (delta.type !== "tool_end" || delta.id !== approvalId) return false;
@@ -1,13 +1,21 @@
1
1
  import { type ComponentType, type MouseEventHandler, type ReactNode } from 'react';
2
2
  import type { Components } from 'streamdown';
3
3
  import { type ChatStrings } from '../strings.js';
4
- import type { ApprovalAnswerValue, ChatLinkTarget, ChatMessage, CspKind, MessagePart, MessagePartKind } from '../types.js';
4
+ import type { ApprovalAnswerValue, ApprovalPart, ChatLinkTarget, ChatMessage, CspKind, MessagePart, MessagePartKind, ToolCallPart } from '../types.js';
5
5
  export interface PartRendererProps {
6
6
  part: MessagePart;
7
7
  message: ChatMessage;
8
8
  isStreaming?: boolean;
9
9
  onApprovalAnswer?: (id: string, answer: ApprovalAnswerValue) => void;
10
10
  }
11
+ export interface ToolRendererProps {
12
+ part: ToolCallPart;
13
+ approval?: ApprovalPart;
14
+ message: ChatMessage;
15
+ isStreaming?: boolean;
16
+ /** Absent when the reader may not answer, such as a call someone else asked for. */
17
+ onApprovalAnswer?: (id: string, answer: ApprovalAnswerValue) => void;
18
+ }
11
19
  export interface ChatLinkProps {
12
20
  target: ChatLinkTarget;
13
21
  className?: string;
@@ -27,6 +35,7 @@ export interface ChatUIConfig {
27
35
  composerUsage?: ReactNode;
28
36
  providerIcon?: (cspKind: CspKind, className?: string) => ReactNode;
29
37
  partRenderers?: Partial<Record<MessagePartKind, ComponentType<PartRendererProps>>>;
38
+ toolRenderers?: Record<string, ComponentType<ToolRendererProps>>;
30
39
  };
31
40
  strings?: {
32
41
  [S in keyof ChatStrings]?: Partial<ChatStrings[S]>;
@@ -27,7 +27,7 @@ export { type SlashCommandOption, type SlashMenuConfig, slashMatches, } from './
27
27
  export type { ComposerPastes, PasteUploadResult, } from './components/usePasteCards.js';
28
28
  export { ChatProvider, type ChatProviderProps, type ChatStream, useChat, useChatStream, } from './core/ChatProvider.js';
29
29
  export { type ChatAction, type ChatState, chatReducer, initialState, } from './core/chatReducer.js';
30
- export type { PartRendererProps } from './core/config.js';
30
+ export type { PartRendererProps, ToolRendererProps } from './core/config.js';
31
31
  export { type ChatLinkProps, type ChatUIConfig, resolveChatConfig, useChatConfig, } from './core/config.js';
32
32
  export { FOCUS_SIDEBAR_SEARCH_EVENT } from './core/events.js';
33
33
  export { getGreeting } from './core/greeting.js';
@@ -91,6 +91,8 @@ export interface ChatStrings {
91
91
  stop: string;
92
92
  /** The status words an agent's working line picks from, one per turn. */
93
93
  words: readonly string[];
94
+ /** The working line while a call waits for the reader to allow it. */
95
+ waitingForAnswer: string;
94
96
  };
95
97
  emptyState: {
96
98
  greeting: string | null;
@@ -116,7 +116,8 @@ var defaultChatStrings = {
116
116
  "Crunching",
117
117
  "Forging",
118
118
  "Cooking"
119
- ]
119
+ ],
120
+ waitingForAnswer: "Waiting for your answer"
120
121
  },
121
122
  emptyState: {
122
123
  greeting: null,
@@ -100,6 +100,9 @@ export type ApprovalAnswerValue = {
100
100
  allowed: boolean;
101
101
  allowDir?: boolean;
102
102
  denyMessage?: string;
103
+ /** A sibling tool to run in place of the one asked for, with the same
104
+ * arguments, such as destroying a cluster the model asked to stop. */
105
+ instead?: string;
103
106
  } | {
104
107
  kind: 'ask';
105
108
  text: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallelworks/ui",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "React UI components: a theme contract, icons, brand logos, primitives, lists, forms, a job graph, a code editor, a log viewer, a file explorer and an AI chat, each on its own subpath",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {