@parallelworks/ui 0.68.0 → 0.69.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.
package/README.md CHANGED
@@ -858,6 +858,18 @@ The chat's links (each conversation, a new chat, attachments, and the
858
858
  these links, so `navigation` handles only the moves that aren't clicks, such as
859
859
  keyboard shortcuts.
860
860
 
861
+ `ChatThread` is `SessionView` bound to the chat provider. A host with its own
862
+ transcript, such as an agent session fed by an event stream, renders
863
+ `SessionView` with its messages, streaming and queued messages, and callbacks:
864
+ `onSend`, and `turn` (`running`, `onStop`) for a turn the provider doesn't run.
865
+ What it leaves out it doesn't offer: `onEdit`, `onRegenerate`, `onRetry` and
866
+ `onNavigateBranch` turn on editing, regenerating, retrying and branches.
867
+ Escape stops the running turn either way. `queueDelivery` says when a queued
868
+ message reaches the model, per message when it takes a function. `composer`
869
+ passes `ChatInput`'s props; `ComposerControls` takes `model` and `allocation`
870
+ as a `BoundSetting` for a session that owns its own, and `tools={false}` keeps
871
+ the chat's tools out of a composer they don't apply to.
872
+
861
873
  `ChatLayout sidebarMode="auto"` shows the conversation list as a drawer over
862
874
  the thread when the chat is narrower than `drawerBelowPx` (`DRAWER_BELOW_PX`, 896, by default),
863
875
  measured on the chat's own box; `"drawer"` always does, and `"inline"` (the
@@ -1,3 +1,9 @@
1
+ import type { BoundSetting } from '../types.js';
1
2
  /** Which allocation an organization model bills, with its spend so far. Shows
2
3
  * only for an organization model, and picks the first allocation for it. */
3
- export default function AllocationSelector(): import("react").JSX.Element | null;
4
+ export default function AllocationSelector({ model, allocation, }?: {
5
+ /** The model a session owns, which decides whether it bills an allocation. */
6
+ model?: string | null | undefined;
7
+ /** The allocation a session owns, in place of the chat's own selection. */
8
+ allocation?: BoundSetting | undefined;
9
+ }): import("react").JSX.Element | null;
@@ -15,22 +15,28 @@ var dollars = new Intl.NumberFormat(void 0, {
15
15
  var money = (amount) => dollars.format(amount);
16
16
  /** Which allocation an organization model bills, with its spend so far. Shows
17
17
  * only for an organization model, and picks the first allocation for it. */
18
- function AllocationSelector() {
19
- const { adapter, selectedProvider, selectedAllocation, setSelectedAllocation } = useChat();
18
+ function AllocationSelector({ model, allocation } = {}) {
19
+ const chat = useChat();
20
20
  const t = useChatConfig().strings.allocationPicker;
21
+ const { adapter } = chat;
22
+ const bound = allocation !== void 0;
23
+ const selectedProvider = bound ? model ?? null : chat.selectedProvider;
24
+ const selectedAllocation = bound ? allocation.value ?? null : chat.selectedAllocation;
21
25
  const isOrgProvider = isOrgModel(selectedProvider);
22
26
  const allocationsAdapter = adapter.allocations;
23
27
  const { data: allocations } = useSWR(isOrgProvider && allocationsAdapter ? ["ai-chat-allocations"] : null, () => allocationsAdapter?.list());
24
28
  const first = allocations?.[0];
25
29
  useEffect(() => {
30
+ if (bound) return;
26
31
  if (!isOrgProvider) {
27
- if (selectedAllocation) setSelectedAllocation(null);
28
- } else if (first && !selectedAllocation) setSelectedAllocation(first.name);
32
+ if (selectedAllocation) chat.setSelectedAllocation(null);
33
+ } else if (first && !selectedAllocation) chat.setSelectedAllocation(first.name);
29
34
  }, [
35
+ bound,
30
36
  isOrgProvider,
31
37
  first,
32
38
  selectedAllocation,
33
- setSelectedAllocation
39
+ chat.setSelectedAllocation
34
40
  ]);
35
41
  if (!isOrgProvider || !allocations?.length) return null;
36
42
  const selected = allocations.find((a) => a.name === selectedAllocation);
@@ -43,7 +49,8 @@ function AllocationSelector() {
43
49
  label: a.name
44
50
  })),
45
51
  value: selectedAllocation,
46
- onValueChange: setSelectedAllocation,
52
+ disabled: !!allocation?.disabled,
53
+ onValueChange: (name) => bound ? name && allocation.onChange(name) : chat.setSelectedAllocation(name),
47
54
  trigger: /* @__PURE__ */ jsxs("button", {
48
55
  type: "button",
49
56
  className: chipClassName,
@@ -42,6 +42,9 @@ interface ChatInputProps {
42
42
  onStop: () => void;
43
43
  stopping?: boolean;
44
44
  };
45
+ /** False where the chat's tools do not apply, such as starting an agent
46
+ * session, which brings its own. */
47
+ tools?: boolean;
45
48
  pastes?: ComposerPastes | undefined;
46
49
  ref?: Ref<ChatInputHandle> | undefined;
47
50
  }
@@ -49,5 +52,5 @@ export interface ChatInputHandle {
49
52
  addFiles: (files: FileList | File[]) => void;
50
53
  focus: () => void;
51
54
  }
52
- export default function ChatInput({ onSend, disabled, placeholder, conversationId, onEditLastMessage, allowEmpty, attachments: attachmentsWanted, requireModel, inputLabel, sendLabel, sendTestId, slash, notices, activity, context, reserveContext, settingsLeft, settingsRight, hint, turn, pastes, ref, }: ChatInputProps): import("react").JSX.Element;
55
+ export default function ChatInput({ onSend, disabled, placeholder, conversationId, onEditLastMessage, allowEmpty, attachments: attachmentsWanted, requireModel, inputLabel, sendLabel, sendTestId, slash, notices, activity, context, reserveContext, settingsLeft, settingsRight, hint, turn, tools: toolsAllowed, pastes, ref, }: ChatInputProps): import("react").JSX.Element;
53
56
  export {};
@@ -58,7 +58,7 @@ function fitToContent(el) {
58
58
  el.style.height = `${el.scrollHeight}px`;
59
59
  }
60
60
  var draftsSwept = false;
61
- function ChatInput({ onSend, disabled = false, placeholder, conversationId, onEditLastMessage, allowEmpty = false, attachments: attachmentsWanted = true, requireModel = true, inputLabel, sendLabel, sendTestId, slash, notices, activity, context, reserveContext = false, settingsLeft, settingsRight = /* @__PURE__ */ jsx(ComposerControls, {}), hint, turn, pastes, ref }) {
61
+ function ChatInput({ onSend, disabled = false, placeholder, conversationId, onEditLastMessage, allowEmpty = false, attachments: attachmentsWanted = true, requireModel = true, inputLabel, sendLabel, sendTestId, slash, notices, activity, context, reserveContext = false, settingsLeft, settingsRight = /* @__PURE__ */ jsx(ComposerControls, {}), hint, turn, tools: toolsAllowed = true, pastes, ref }) {
62
62
  const [input, setInput] = useState(() => readDraft(conversationId));
63
63
  useEffect(() => {
64
64
  if (!draftsSwept) {
@@ -186,7 +186,7 @@ function ChatInput({ onSend, disabled = false, placeholder, conversationId, onEd
186
186
  if (pastes && handleTextPaste(e.clipboardData.getData("text/plain"))) e.preventDefault();
187
187
  };
188
188
  const { isDragging, dragHandlers } = useDragDrop({ onFilesDropped: handleFilesDropped });
189
- const toolsAvailable = !turn && tools.length > 0;
189
+ const toolsAvailable = toolsAllowed && !turn && tools.length > 0;
190
190
  const toolbar = attachmentsAvailable || toolsAvailable || !!settingsRight;
191
191
  const action = isStreaming && !input.trim() ? /* @__PURE__ */ jsx("button", {
192
192
  type: "button",
@@ -275,7 +275,7 @@ function ChatInput({ onSend, disabled = false, placeholder, conversationId, onEd
275
275
  attachmentsOpen: showAttachments && attachmentsAvailable,
276
276
  onToggleAttachments: attachmentsAvailable ? () => setShowAttachments(!showAttachments) : void 0,
277
277
  disabled,
278
- withTools: !turn
278
+ withTools: toolsAllowed && !turn
279
279
  }), /* @__PURE__ */ jsxs("div", {
280
280
  className: "ml-auto flex min-w-0 items-center gap-1",
281
281
  children: [/* @__PURE__ */ jsx("div", {
@@ -85,19 +85,57 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
85
85
  const maxHeight = 200;
86
86
  e.target.style.height = `${Math.min(e.target.scrollHeight, maxHeight)}px`;
87
87
  };
88
- const hasParts = !!message.parts?.length;
89
- if (hasParts && !message.content) return /* @__PURE__ */ jsx("div", {
90
- className: cx("group py-3 w-full", !flush && "px-4"),
91
- children: /* @__PURE__ */ jsx(AgentMessageParts, {
92
- message,
93
- isStreaming,
94
- onApprovalAnswer
95
- })
88
+ const timestamp = parseTimestamp(message.timestamp);
89
+ const totalTokens = message.tokensUsed?.total_tokens;
90
+ const meta = (timestamp || !isUser && (message.model || totalTokens)) && /* @__PURE__ */ jsxs("span", {
91
+ className: cx("chat-meta flex items-center gap-2 ml-2 text-xs tabular-nums theme-muted-text transition-opacity duration-150 select-none", "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"),
92
+ children: [
93
+ /* @__PURE__ */ jsx(RelativeTime, { value: timestamp }),
94
+ !isUser && message.model && /* @__PURE__ */ jsx("span", { children: message.model }),
95
+ !isUser && totalTokens ? /* @__PURE__ */ jsx("span", {
96
+ title: message.tokensUsed?.prompt_tokens !== void 0 && message.tokensUsed?.completion_tokens !== void 0 ? t.tokensBreakdown(message.tokensUsed.prompt_tokens, message.tokensUsed.completion_tokens) : void 0,
97
+ children: t.tokens(totalTokens)
98
+ }) : null
99
+ ]
96
100
  });
101
+ const hasParts = !!message.parts?.length;
102
+ if (hasParts && !message.content) {
103
+ const said = message.parts?.flatMap((p) => p.kind === "text" && p.text.trim() ? [p.text.trim()] : []).join("\n\n");
104
+ return /* @__PURE__ */ jsxs("div", {
105
+ className: cx("group py-3 w-full", !flush && "px-4"),
106
+ children: [/* @__PURE__ */ jsx(AgentMessageParts, {
107
+ message,
108
+ isStreaming,
109
+ onApprovalAnswer
110
+ }), !isUser && !isStreaming && (said || onRegenerate || meta) && /* @__PURE__ */ jsxs("div", {
111
+ className: "chat-actions -ml-1.5 mt-2 flex items-center gap-0.5",
112
+ children: [
113
+ said && /* @__PURE__ */ jsx(CopyButton, {
114
+ text: said,
115
+ label: t.copyLabel,
116
+ quiet: true,
117
+ size: "md",
118
+ className: actionClasses
119
+ }),
120
+ onRegenerate && /* @__PURE__ */ jsx(IconButton, {
121
+ label: t.regenerate,
122
+ onClick: () => onRegenerate(message.id),
123
+ size: "md",
124
+ className: actionClasses,
125
+ icon: /* @__PURE__ */ jsx(RetryIcon, {})
126
+ }),
127
+ onNavigateBranch && /* @__PURE__ */ jsx(BranchNavigator, {
128
+ messages: allMessages,
129
+ currentMessageId: message.id,
130
+ onNavigate: onNavigateBranch
131
+ }),
132
+ meta
133
+ ]
134
+ })]
135
+ });
136
+ }
97
137
  if (isTool) return null;
98
138
  if (!isUser && !message.content && message.toolCalls?.length) return null;
99
- const timestamp = parseTimestamp(message.timestamp);
100
- const totalTokens = message.tokensUsed?.total_tokens;
101
139
  const collapsed = !isUser && collapsible && !expanded && !isStreaming && !latest;
102
140
  return /* @__PURE__ */ jsx("div", {
103
141
  role: "none",
@@ -246,17 +284,7 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
246
284
  currentMessageId: message.id,
247
285
  onNavigate: onNavigateBranch
248
286
  }),
249
- (timestamp || !isUser && (message.model || totalTokens)) && /* @__PURE__ */ jsxs("span", {
250
- className: cx("chat-meta flex items-center gap-2 ml-2 text-xs tabular-nums theme-muted-text transition-opacity duration-150 select-none", "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"),
251
- children: [
252
- /* @__PURE__ */ jsx(RelativeTime, { value: timestamp }),
253
- !isUser && message.model && /* @__PURE__ */ jsx("span", { children: message.model }),
254
- !isUser && totalTokens ? /* @__PURE__ */ jsx("span", {
255
- title: message.tokensUsed?.prompt_tokens !== void 0 && message.tokensUsed?.completion_tokens !== void 0 ? t.tokensBreakdown(message.tokensUsed.prompt_tokens, message.tokensUsed.completion_tokens) : void 0,
256
- children: t.tokens(totalTokens)
257
- }) : null
258
- ]
259
- })
287
+ meta
260
288
  ]
261
289
  })
262
290
  ]
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { ApprovalAnswerValue, ChatMessage as Message } from '../types.js';
3
+ export type QueueDelivery = 'afterReply' | 'nextStep';
3
4
  interface ChatMessageListProps {
4
5
  messages: Message[];
5
6
  allMessages: Message[];
@@ -13,8 +14,9 @@ interface ChatMessageListProps {
13
14
  canRemoveQueued?: (id: string) => boolean;
14
15
  /** When a queued message reaches the model: once the running reply
15
16
  * finishes (the chat provider's queue), or between the agent's steps for a
16
- * host that steers the turn in progress. */
17
- queueDelivery?: 'afterReply' | 'nextStep';
17
+ * host that steers the turn in progress. A host with both kinds says which
18
+ * each message is. */
19
+ queueDelivery?: QueueDelivery | ((messageId: string) => QueueDelivery);
18
20
  editingMessageId?: string | null | undefined;
19
21
  showAuthor?: boolean;
20
22
  currentUsername?: string;
@@ -253,7 +253,7 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
253
253
  }, "streaming"),
254
254
  queuedMessages?.map((msg) => {
255
255
  const removable = canRemoveQueued?.(msg.id) ?? true;
256
- const status = !removable ? strings.queue.handedOver : queueDelivery === "nextStep" ? strings.queue.waitingNextStep : strings.queue.waiting;
256
+ const status = !removable ? strings.queue.handedOver : (typeof queueDelivery === "function" ? queueDelivery(msg.id) : queueDelivery) === "nextStep" ? strings.queue.waitingNextStep : strings.queue.waiting;
257
257
  return /* @__PURE__ */ jsxs("div", {
258
258
  className: cx("animate-chat-enter", removable && "chat-queued"),
259
259
  children: [/* @__PURE__ */ jsx(ChatMessage_default, {
@@ -1,19 +1,14 @@
1
1
  import { useChatConfig } from "../core/config.js";
2
2
  import { useChat } from "../core/ChatProvider.js";
3
3
  import BlockedGroupBanner from "./BlockedGroupBanner.js";
4
- import useDragDrop from "../core/useDragDrop.js";
5
4
  import { ComposerControls, ComposerUsage, ConnectToolsLink } from "./ComposerChrome.js";
6
- import DragOverlay from "./DragOverlay.js";
7
- import ChatInput from "./ChatInput.js";
8
5
  import ProviderIssueBanner from "./ProviderIssueBanner.js";
9
6
  import { ShareButton } from "./ShareDialog.js";
10
- import ChatMessageList from "./ChatMessageList.js";
7
+ import SessionView from "./SessionView.js";
11
8
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
+ import { useCallback, useEffect, useMemo, useRef } from "react";
13
10
  //#region src/ai/components/ChatThread.tsx
14
11
  function ChatThread({ conversationId }) {
15
- const chatInputRef = useRef(null);
16
- const [editingState, setEditingState] = useState(null);
17
12
  const { adapter, currentUser, currentConversation, loadConversation, loadProviders, sendMessage, retryMessage, isLoadingConversation, navigateToBranch, queuedMessages, removeQueuedMessage } = useChat();
18
13
  const { slots, strings } = useChatConfig();
19
14
  const requestedConversationIdRef = useRef(null);
@@ -28,98 +23,46 @@ function ChatThread({ conversationId }) {
28
23
  loadConversation,
29
24
  loadProviders
30
25
  ]);
31
- const handleSendMessage = useCallback((content, attachmentIds, attachmentMeta) => {
26
+ const messages = currentConversation?.messages ?? [];
27
+ const canWrite = currentConversation?.canCollaborate !== false;
28
+ const onSend = useCallback((content, attachmentIds, attachmentMeta) => {
32
29
  sendMessage(content, attachmentIds, void 0, attachmentMeta);
33
30
  }, [sendMessage]);
34
- const handleEditMessage = useCallback((messageId) => {
35
- const message = (currentConversation?.messages || []).find((m) => m.id === messageId);
36
- if (message && message.role === "user") setEditingState({
37
- messageId: message.id,
38
- parentId: message.parentId || null
39
- });
40
- }, [currentConversation?.messages]);
41
- const handleSaveEdit = useCallback((_messageId, content) => {
42
- if (!editingState) return;
43
- sendMessage(content, void 0, editingState.parentId || void 0);
44
- setEditingState(null);
45
- }, [sendMessage, editingState]);
46
- const handleCancelEdit = useCallback(() => {
47
- setEditingState(null);
48
- }, []);
49
- const handleEditLastMessage = useCallback(() => {
50
- const lastUserMessage = (currentConversation?.messages || []).findLast((m) => m.role === "user");
51
- if (lastUserMessage) handleEditMessage(lastUserMessage.id);
52
- }, [currentConversation?.messages, handleEditMessage]);
53
- const handleNavigateBranch = useCallback((messageId) => {
54
- navigateToBranch?.(messageId);
55
- }, [navigateToBranch]);
56
- const handleRegenerateMessage = useCallback((messageId) => {
57
- const conversationMessages = currentConversation?.messages || [];
58
- const assistantMessage = conversationMessages.find((m) => m.id === messageId);
59
- if (assistantMessage?.role !== "assistant") return;
60
- const userMessage = conversationMessages.find((m) => m.id === assistantMessage.parentId);
61
- if (userMessage?.role !== "user") return;
62
- sendMessage(userMessage.content || "", userMessage.attachments?.map((a) => a.id), userMessage.parentId || void 0);
63
- }, [currentConversation?.messages, sendMessage]);
64
- const handleRetryMessage = useCallback((messageId) => {
65
- retryMessage(messageId);
66
- }, [retryMessage]);
67
- const handleFilesDropped = useCallback((files) => {
68
- chatInputRef.current?.addFiles(files);
69
- }, []);
70
- const { isDragging, dragHandlers } = useDragDrop({ onFilesDropped: handleFilesDropped });
71
- const attachmentsAvailable = !!adapter.attachments;
72
- const messages = currentConversation?.messages || [];
31
+ const onEdit = useCallback((message, content) => {
32
+ sendMessage(content, void 0, message.parentId || void 0);
33
+ }, [sendMessage]);
34
+ const onRegenerate = useCallback((messageId) => {
35
+ const reply = messages.find((m) => m.id === messageId);
36
+ const asked = messages.find((m) => m.id === reply?.parentId);
37
+ if (reply?.role !== "assistant" || asked?.role !== "user") return;
38
+ sendMessage(asked.content || "", asked.attachments?.map((a) => a.id), asked.parentId || void 0);
39
+ }, [messages, sendMessage]);
73
40
  const showAuthor = useMemo(() => {
74
41
  return new Set(messages.flatMap((m) => m.author ? [m.author.username] : [])).size > 1 || currentConversation?.isOwner === false;
75
42
  }, [messages, currentConversation?.isOwner]);
76
- if (isLoadingConversation && !currentConversation) return /* @__PURE__ */ jsxs("div", {
77
- className: "flex flex-col h-full bg-(--theme-panel-bg)",
78
- children: [/* @__PURE__ */ jsx("div", { className: "flex-1" }), /* @__PURE__ */ jsx(ChatInput, {
79
- ref: chatInputRef,
80
- onSend: handleSendMessage,
81
- disabled: true,
82
- conversationId,
83
- settingsLeft: /* @__PURE__ */ jsx(ConnectToolsLink, {}),
43
+ return /* @__PURE__ */ jsx(SessionView, {
44
+ sessionKey: conversationId,
45
+ messages,
46
+ queuedMessages,
47
+ onRemoveQueued: removeQueuedMessage,
48
+ onSend,
49
+ ...canWrite ? {
50
+ onEdit,
51
+ onRegenerate,
52
+ onRetry: (id) => void retryMessage(id)
53
+ } : {},
54
+ onNavigateBranch: navigateToBranch,
55
+ showAuthor,
56
+ currentUsername: currentUser.username,
57
+ loading: isLoadingConversation && !currentConversation,
58
+ dropFiles: !!adapter.attachments,
59
+ banners: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(BlockedGroupBanner, {}), /* @__PURE__ */ jsx(ProviderIssueBanner, {})] }),
60
+ composer: {
61
+ disabled: isLoadingConversation || !canWrite,
62
+ ...canWrite ? {} : { placeholder: strings.thread.viewOnly },
63
+ settingsLeft: /* @__PURE__ */ jsxs(Fragment, { children: [currentConversation && currentConversation.isOwner !== false && /* @__PURE__ */ jsx(ShareButton, { conversationId }), /* @__PURE__ */ jsx(ConnectToolsLink, {})] }),
84
64
  settingsRight: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ComposerUsage, { children: slots.composerUsage }), /* @__PURE__ */ jsx(ComposerControls, {})] })
85
- })]
86
- });
87
- return /* @__PURE__ */ jsx("div", {
88
- className: "flex h-full relative bg-(--theme-panel-bg)",
89
- children: /* @__PURE__ */ jsxs("div", {
90
- className: "flex-1 flex flex-col min-w-0",
91
- ...attachmentsAvailable ? dragHandlers : {},
92
- children: [
93
- isDragging && attachmentsAvailable && /* @__PURE__ */ jsx(DragOverlay, { className: "rounded-lg" }),
94
- /* @__PURE__ */ jsx(BlockedGroupBanner, {}),
95
- /* @__PURE__ */ jsx(ProviderIssueBanner, {}),
96
- /* @__PURE__ */ jsx(ChatMessageList, {
97
- messages,
98
- allMessages: messages,
99
- queuedMessages,
100
- onRemoveQueued: removeQueuedMessage,
101
- editingMessageId: editingState?.messageId,
102
- showAuthor,
103
- currentUsername: currentUser.username,
104
- onNavigateBranch: handleNavigateBranch,
105
- onEditMessage: currentConversation?.canCollaborate !== false ? handleEditMessage : void 0,
106
- onRegenerateMessage: currentConversation?.canCollaborate !== false ? handleRegenerateMessage : void 0,
107
- onRetryMessage: currentConversation?.canCollaborate !== false ? handleRetryMessage : void 0,
108
- onSaveEdit: handleSaveEdit,
109
- onCancelEdit: handleCancelEdit,
110
- inputElement: /* @__PURE__ */ jsx(ChatInput, {
111
- ref: chatInputRef,
112
- onSend: handleSendMessage,
113
- disabled: isLoadingConversation || currentConversation?.canCollaborate === false,
114
- placeholder: currentConversation?.canCollaborate === false ? strings.thread.viewOnly : void 0,
115
- conversationId,
116
- onEditLastMessage: handleEditLastMessage,
117
- settingsLeft: /* @__PURE__ */ jsxs(Fragment, { children: [currentConversation?.isOwner !== false && /* @__PURE__ */ jsx(ShareButton, { conversationId }), /* @__PURE__ */ jsx(ConnectToolsLink, {})] }),
118
- settingsRight: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ComposerUsage, { children: slots.composerUsage }), /* @__PURE__ */ jsx(ComposerControls, {})] })
119
- })
120
- })
121
- ]
122
- })
65
+ }
123
66
  });
124
67
  }
125
68
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { BoundSetting } from '../types.js';
2
3
  /** Where the turn runs, sitting above the composer the way the terminal shows
3
4
  * it: the machine, the workspace, and whatever else names the place. */
4
5
  export declare function ComposerContext({ children }: {
@@ -19,8 +20,12 @@ export declare function ComposerUsage({ children }: {
19
20
  }): import("react").JSX.Element | null;
20
21
  /** The two selectors every surface carries, reading as text beside whatever
21
22
  * the surface puts next to them. */
22
- export declare function ComposerControls({ targetSession }: {
23
+ export declare function ComposerControls({ targetSession, model, allocation, }: {
23
24
  targetSession?: string | null | undefined;
25
+ /** A session's own model and allocation, for a session that keeps them
26
+ * apart from the chat's selection. */
27
+ model?: BoundSetting | undefined;
28
+ allocation?: BoundSetting | undefined;
24
29
  }): import("react").JSX.Element;
25
30
  /** Where the reader manages the providers behind the model picker, at the
26
31
  * foot of a conversation list. */
@@ -40,8 +40,14 @@ function ComposerUsage({ children }) {
40
40
  }
41
41
  /** The two selectors every surface carries, reading as text beside whatever
42
42
  * the surface puts next to them. */
43
- function ComposerControls({ targetSession }) {
44
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ModelSelector, { targetSession }), /* @__PURE__ */ jsx(AllocationSelector, {})] });
43
+ function ComposerControls({ targetSession, model, allocation }) {
44
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ModelSelector, {
45
+ targetSession,
46
+ model
47
+ }), /* @__PURE__ */ jsx(AllocationSelector, { ...allocation ? {
48
+ allocation,
49
+ model: model ? model.value : void 0
50
+ } : {} })] });
45
51
  }
46
52
  /** Where the reader manages the providers behind the model picker, at the
47
53
  * foot of a conversation list. */
@@ -1,8 +1,11 @@
1
+ import type { BoundSetting } from '../types.js';
1
2
  export declare function getProviderKeyFromModelId(modelId: string): string;
2
3
  /** Picks the model a conversation runs on, grouped by provider. Sessions that
3
4
  * are still connecting or not responding show as headings with their status,
4
5
  * so the reader sees why their models are missing. */
5
- export default function ModelSelector({ targetSession, }: {
6
+ export default function ModelSelector({ targetSession, model, }: {
6
7
  /** A session, as "owner:name", whose models the reader is waiting for. */
7
8
  targetSession?: string | null | undefined;
9
+ /** The model a session owns, in place of the chat's own selection. */
10
+ model?: BoundSetting | undefined;
8
11
  }): import("react").JSX.Element;
@@ -50,11 +50,12 @@ function groupModelsByProvider(models) {
50
50
  /** Picks the model a conversation runs on, grouped by provider. Sessions that
51
51
  * are still connecting or not responding show as headings with their status,
52
52
  * so the reader sees why their models are missing. */
53
- function ModelSelector({ targetSession }) {
54
- const { providers, models, unreachableSessions, providerIssues, selectedProvider, setSelectedProvider, isLoadingModels, hasLoadedModels, modelsError, refreshModels, currentUser } = useChat();
53
+ function ModelSelector({ targetSession, model }) {
54
+ const { providers, models, unreachableSessions, providerIssues, selectedProvider: chatSelection, setSelectedProvider, isLoadingModels, hasLoadedModels, modelsError, refreshModels, currentUser } = useChat();
55
55
  const { strings } = useChatConfig();
56
56
  const { common } = useStrings();
57
57
  const t = strings.modelPicker;
58
+ const selectedProvider = model ? model.value ?? null : chatSelection;
58
59
  const targetSessionFound = !!targetSession && models.some((m) => m.id.startsWith(`session:${targetSession}/`));
59
60
  const targetSessionStatus = !targetSession ? null : isLoadingModels ? "connecting" : hasLoadedModels && !targetSessionFound ? "offline" : null;
60
61
  const groups = useMemo(() => {
@@ -128,8 +129,8 @@ function ModelSelector({ targetSession }) {
128
129
  placeholder: t.search,
129
130
  items: groups,
130
131
  value: selectedProvider,
131
- onValueChange: (id) => id && setSelectedProvider(id),
132
- disabled: groups.length === 0,
132
+ onValueChange: (id) => id && (model ? model.onChange(id) : setSelectedProvider(id)),
133
+ disabled: groups.length === 0 || !!model?.disabled,
133
134
  triggerTooltip: issue ? `${issueLabel(strings.providerIssue, issue)} \u2014 ${issueDetail(strings.providerIssue, issue)}` : void 0,
134
135
  trigger: /* @__PURE__ */ jsxs("button", {
135
136
  type: "button",
@@ -0,0 +1,56 @@
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
+ import type { ApprovalAnswerValue, AttachmentMeta, ChatMessage as Message } from '../types.js';
3
+ import ChatInput from './ChatInput.js';
4
+ import ChatMessageList from './ChatMessageList.js';
5
+ type ComposerProps = Omit<ComponentProps<typeof ChatInput>, 'ref' | 'onSend' | 'turn' | 'conversationId' | 'onEditLastMessage'>;
6
+ type ListProps = ComponentProps<typeof ChatMessageList>;
7
+ export interface SessionViewProps {
8
+ /** Keys the composer's draft, so each conversation or session keeps its own. */
9
+ sessionKey: string;
10
+ messages: Message[];
11
+ /** Every message branch navigation and turn attribution look through;
12
+ * defaults to `messages`. */
13
+ allMessages?: Message[];
14
+ streamingMessage?: Message | null;
15
+ queuedMessages?: Message[];
16
+ onRemoveQueued?: (id: string) => void;
17
+ canRemoveQueued?: (id: string) => boolean;
18
+ queueDelivery?: ListProps['queueDelivery'];
19
+ /** A turn the chat provider does not run, such as an agent session's: it
20
+ * drives the composer's Stop and Escape in place of the provider's stream. */
21
+ turn?: {
22
+ running: boolean;
23
+ onStop: () => void;
24
+ stopping?: boolean;
25
+ };
26
+ onSend: (content: string, attachmentIds?: string[], attachmentMeta?: AttachmentMeta) => void;
27
+ /** Resends an edited user message as a new branch. Without it, messages
28
+ * cannot be edited. */
29
+ onEdit?: (message: Message, content: string) => void;
30
+ onRegenerate?: (messageId: string) => void;
31
+ onRetry?: (messageId: string) => void;
32
+ onNavigateBranch?: (messageId: string) => void;
33
+ onApprovalAnswer?: (id: string, answer: ApprovalAnswerValue) => void;
34
+ showAuthor?: boolean;
35
+ currentUsername?: string;
36
+ /** Shown above the transcript, such as a blocked allocation or an
37
+ * unreachable machine. */
38
+ banners?: ReactNode;
39
+ /** In place of the transcript, such as a failed load with a way to retry. */
40
+ error?: ReactNode;
41
+ /** Still loading the first messages: the transcript stays empty and the
42
+ * composer disabled. */
43
+ loading?: boolean;
44
+ /** Files dropped anywhere on the view go to the composer's attachments. */
45
+ dropFiles?: boolean;
46
+ footer?: ReactNode;
47
+ label?: string;
48
+ flush?: boolean;
49
+ workingIndicator?: boolean;
50
+ composer?: ComposerProps;
51
+ }
52
+ /** One conversation or agent session: its transcript, with the composer at
53
+ * the foot of the scrolling column. The host binds its own data and actions;
54
+ * what it leaves out (editing, regenerating, branches) the view leaves out. */
55
+ export default function SessionView({ sessionKey, messages, allMessages, streamingMessage, queuedMessages, onRemoveQueued, canRemoveQueued, queueDelivery, turn, onSend, onEdit, onRegenerate, onRetry, onNavigateBranch, onApprovalAnswer, showAuthor, currentUsername, banners, error, loading, dropFiles, footer, label, flush, workingIndicator, composer, }: SessionViewProps): import("react").JSX.Element;
56
+ export {};
@@ -0,0 +1,99 @@
1
+ import { useChat } from "../core/ChatProvider.js";
2
+ import useDragDrop from "../core/useDragDrop.js";
3
+ import DragOverlay from "./DragOverlay.js";
4
+ import ChatInput from "./ChatInput.js";
5
+ import { escapeClosesLayer } from "../core/useKeyboardShortcuts.js";
6
+ import ChatMessageList from "./ChatMessageList.js";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { useCallback, useEffect, useRef, useState } from "react";
9
+ //#region src/ai/components/SessionView.tsx
10
+ /** One conversation or agent session: its transcript, with the composer at
11
+ * the foot of the scrolling column. The host binds its own data and actions;
12
+ * what it leaves out (editing, regenerating, branches) the view leaves out. */
13
+ function SessionView({ sessionKey, messages, allMessages, streamingMessage, queuedMessages, onRemoveQueued, canRemoveQueued, queueDelivery, turn, onSend, onEdit, onRegenerate, onRetry, onNavigateBranch, onApprovalAnswer, showAuthor, currentUsername, banners, error, loading = false, dropFiles = false, footer, label, flush, workingIndicator, composer }) {
14
+ const inputRef = useRef(null);
15
+ const [editingId, setEditingId] = useState(null);
16
+ const { isStreaming: providerStreaming, stopStreaming } = useChat();
17
+ const running = turn ? turn.running : providerStreaming;
18
+ const stop = turn ? turn.onStop : stopStreaming;
19
+ useEffect(() => {
20
+ if (!running) return;
21
+ const onKeyDown = (e) => {
22
+ if (e.key === "Escape" && !e.defaultPrevented && !escapeClosesLayer(e.target)) stop();
23
+ };
24
+ window.addEventListener("keydown", onKeyDown);
25
+ return () => window.removeEventListener("keydown", onKeyDown);
26
+ }, [running, stop]);
27
+ const editable = (id) => messages.find((m) => m.id === id && m.role === "user");
28
+ const startEdit = useCallback((id) => {
29
+ if (messages.some((m) => m.id === id && m.role === "user")) setEditingId(id);
30
+ }, [messages]);
31
+ const editLast = useCallback(() => {
32
+ const last = messages.findLast((m) => m.role === "user");
33
+ if (last) setEditingId(last.id);
34
+ }, [messages]);
35
+ const onFilesDropped = useCallback((files) => {
36
+ inputRef.current?.addFiles(files);
37
+ }, []);
38
+ const { isDragging, dragHandlers } = useDragDrop({ onFilesDropped });
39
+ const input = /* @__PURE__ */ jsx(ChatInput, {
40
+ ref: inputRef,
41
+ ...composer,
42
+ onSend,
43
+ conversationId: sessionKey,
44
+ disabled: loading || !!composer?.disabled,
45
+ ...turn ? { turn } : {},
46
+ ...onEdit ? { onEditLastMessage: editLast } : {}
47
+ });
48
+ if (loading && !error) return /* @__PURE__ */ jsxs("div", {
49
+ className: "flex h-full flex-col bg-(--theme-panel-bg)",
50
+ children: [
51
+ banners,
52
+ /* @__PURE__ */ jsx("div", { className: "flex-1" }),
53
+ input
54
+ ]
55
+ });
56
+ return /* @__PURE__ */ jsx("div", {
57
+ className: "relative flex h-full bg-(--theme-panel-bg)",
58
+ children: /* @__PURE__ */ jsxs("div", {
59
+ className: "flex min-w-0 flex-1 flex-col",
60
+ ...dropFiles ? dragHandlers : {},
61
+ children: [
62
+ isDragging && dropFiles && /* @__PURE__ */ jsx(DragOverlay, { className: "rounded-lg" }),
63
+ banners,
64
+ error ?? /* @__PURE__ */ jsx(ChatMessageList, {
65
+ messages,
66
+ allMessages: allMessages ?? messages,
67
+ ...streamingMessage !== void 0 ? { streamingMessage } : {},
68
+ ...queuedMessages ? { queuedMessages } : {},
69
+ ...onRemoveQueued ? { onRemoveQueued } : {},
70
+ ...canRemoveQueued ? { canRemoveQueued } : {},
71
+ ...queueDelivery ? { queueDelivery } : {},
72
+ ...turn ? { isStreaming: turn.running } : {},
73
+ editingMessageId: editingId,
74
+ ...showAuthor !== void 0 ? { showAuthor } : {},
75
+ ...currentUsername ? { currentUsername } : {},
76
+ ...onNavigateBranch ? { onNavigateBranch } : {},
77
+ onEditMessage: onEdit ? startEdit : void 0,
78
+ onSaveEdit: (id, content) => {
79
+ const message = editable(id);
80
+ if (message && onEdit) onEdit(message, content);
81
+ setEditingId(null);
82
+ },
83
+ onCancelEdit: () => setEditingId(null),
84
+ onRegenerateMessage: onRegenerate,
85
+ onRetryMessage: onRetry,
86
+ ...onApprovalAnswer ? { onApprovalAnswer } : {},
87
+ ...footer ? { footer } : {},
88
+ ...label ? { label } : {},
89
+ ...flush ? { flush } : {},
90
+ ...workingIndicator !== void 0 ? { workingIndicator } : {},
91
+ inputElement: input
92
+ }),
93
+ error && input
94
+ ]
95
+ })
96
+ });
97
+ }
98
+ //#endregion
99
+ export { SessionView as default };
@@ -44,10 +44,6 @@ function useKeyboardShortcuts(options = {}) {
44
44
  if (!enabled) return;
45
45
  const handleKeyDown = (e) => {
46
46
  const cmdKey = isMac() ? e.metaKey : e.ctrlKey;
47
- if (e.key === "Escape") {
48
- if (!escapeClosesLayer(e.target)) handleStopGeneration();
49
- return;
50
- }
51
47
  if (cmdKey && e.shiftKey && e.key === "O") {
52
48
  e.preventDefault();
53
49
  handleNewConversation();
@@ -69,8 +65,7 @@ function useKeyboardShortcuts(options = {}) {
69
65
  }, [
70
66
  enabled,
71
67
  handleNewConversation,
72
- handleCopyLastResponse,
73
- handleStopGeneration
68
+ handleCopyLastResponse
74
69
  ]);
75
70
  return {
76
71
  getLastUserMessage,
@@ -21,6 +21,7 @@ export { type ConversationGroupKey, groupConversationsByDate, useConversationAct
21
21
  export { default as DragOverlay } from './components/DragOverlay.js';
22
22
  export { default as ModelSelector, getProviderKeyFromModelId, } from './components/ModelSelector.js';
23
23
  export { default as ProviderIssueBanner } from './components/ProviderIssueBanner.js';
24
+ export { default as SessionView, type SessionViewProps } from './components/SessionView.js';
24
25
  export { default as ShareDialog, ShareButton, } from './components/ShareDialog.js';
25
26
  export { type SlashCommandOption, type SlashMenuConfig, slashMatches, } from './components/SlashMenu.js';
26
27
  export type { ComposerPastes, PasteUploadResult, } from './components/usePasteCards.js';
package/dist/ai/index.js CHANGED
@@ -43,6 +43,7 @@ import ChatSidebar from "./components/ChatSidebar.js";
43
43
  import ChatLayout, { CHAT_COLUMN_SCOPE, ConversationLayout, DRAWER_BELOW_PX } from "./components/ChatLayout.js";
44
44
  import ChatMessage_default from "./components/ChatMessage.js";
45
45
  import ChatMessageList from "./components/ChatMessageList.js";
46
+ import SessionView from "./components/SessionView.js";
46
47
  import ChatThread from "./components/ChatThread.js";
47
48
  import { NoticeBar, NoticeCard, OutputCard } from "./ui/Notice.js";
48
- export { AGENT_COLOR_NAMES, ActivityLine, AgentMessageParts, AllocationSelector, ApprovalBlock, AttachmentManager, AttachmentUpload, BlockedGroupBanner, BranchNavigator, CHAT_COLUMN_SCOPE, ChatAdapterError, ChatEmptyState, ChatInput, ChatLayout, ChatMessageList, ChatMessage_default as ChatMessageView, ChatProvider, ChatSidebar, ChatStage, ChatThread, ComposerContext, ComposerControls, ComposerSettings, ComposerUsage, ConnectToolsLink, ConversationLayout, ConversationSidebar, DEFAULT_PERMISSION_MODE, DRAWER_BELOW_PX, DragOverlay, FOCUS_SIDEBAR_SEARCH_EVENT, INTERRUPTED_NOTICE, KeyboardShortcutsProvider, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, ManageProvidersLink, ModelSelector, NAMED_PERMISSION_MODES, NoticeBar, NoticeCard, OutputCard, PermissionPicker, ProviderIssueBanner, RenameDialog, ShareButton, ShareDialog, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarRowTitle, SidebarToggle, agentColorHex, agentTypeLabel, applyPartDelta, askQuestionFromArgs, bashExitCode, chatReducer, clampPermissionMode, classifyPseudoUserMessage, clip, computeEditDiff, computeWriteRows, defaultChatStrings, defaultToolStrings, editSummary, finalizeParts, firstNonEmptyLine, fullToolArgs, getGreeting, getProviderKeyFromModelId, groupConversationsByDate, hiddenTranscriptTool, initialState, interruptionNotice, isEditDiffTool, isGroupableTool, isMac, isNamedPermissionMode, isOrgModel, languageForPath, lineDelta, parseEditArgs, parseSubagentNotice, pasteInlineMaxBytes, permissionModesUpTo, planFromArgs, providerIssueFor, resolveChatConfig, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, sidebarDrawerProps, slashMatches, subagentDeliveryLine, summarizeToolArgs, taskCallFromArgs, thinkingHeading, tokenizeDiffLines, toolHeaderFor, toolSummary, useChat, useChatConfig, useChatStream, useConversationActions, useDragDrop, useKeyboardShortcuts, useRowDialogs, wordEmphasisRanges };
49
+ export { AGENT_COLOR_NAMES, ActivityLine, AgentMessageParts, AllocationSelector, ApprovalBlock, AttachmentManager, AttachmentUpload, BlockedGroupBanner, BranchNavigator, CHAT_COLUMN_SCOPE, ChatAdapterError, ChatEmptyState, ChatInput, ChatLayout, ChatMessageList, ChatMessage_default as ChatMessageView, ChatProvider, ChatSidebar, ChatStage, ChatThread, ComposerContext, ComposerControls, ComposerSettings, ComposerUsage, ConnectToolsLink, ConversationLayout, ConversationSidebar, DEFAULT_PERMISSION_MODE, DRAWER_BELOW_PX, DragOverlay, FOCUS_SIDEBAR_SEARCH_EVENT, INTERRUPTED_NOTICE, KeyboardShortcutsProvider, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, ManageProvidersLink, ModelSelector, NAMED_PERMISSION_MODES, NoticeBar, NoticeCard, OutputCard, PermissionPicker, ProviderIssueBanner, RenameDialog, SessionView, ShareButton, ShareDialog, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarRowTitle, SidebarToggle, agentColorHex, agentTypeLabel, applyPartDelta, askQuestionFromArgs, bashExitCode, chatReducer, clampPermissionMode, classifyPseudoUserMessage, clip, computeEditDiff, computeWriteRows, defaultChatStrings, defaultToolStrings, editSummary, finalizeParts, firstNonEmptyLine, fullToolArgs, getGreeting, getProviderKeyFromModelId, groupConversationsByDate, hiddenTranscriptTool, initialState, interruptionNotice, isEditDiffTool, isGroupableTool, isMac, isNamedPermissionMode, isOrgModel, languageForPath, lineDelta, parseEditArgs, parseSubagentNotice, pasteInlineMaxBytes, permissionModesUpTo, planFromArgs, providerIssueFor, resolveChatConfig, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, sidebarDrawerProps, slashMatches, subagentDeliveryLine, summarizeToolArgs, taskCallFromArgs, thinkingHeading, tokenizeDiffLines, toolHeaderFor, toolSummary, useChat, useChatConfig, useChatStream, useConversationActions, useDragDrop, useKeyboardShortcuts, useRowDialogs, wordEmphasisRanges };
@@ -1,3 +1,10 @@
1
+ /** A turn setting a session owns, shown in place of the chat's own selection. */
2
+ export interface BoundSetting {
3
+ value: string | null | undefined;
4
+ onChange: (value: string) => void;
5
+ /** The session cannot take a change right now, such as while it is unreachable. */
6
+ disabled?: boolean | undefined;
7
+ }
1
8
  export interface ChatUser {
2
9
  id: string;
3
10
  username: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallelworks/ui",
3
- "version": "0.68.0",
3
+ "version": "0.69.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": {