quassel 0.2.2 → 0.3.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.
@@ -0,0 +1,2 @@
1
+ /** Ziel offener Aktionen: undefined = im Verlauf, null = das Dock über der Eingabe ist noch nicht eingehängt. */
2
+ export declare const ChatActionDock: import("react").Context<HTMLElement | null | undefined>;
package/ChatMessages.d.ts CHANGED
@@ -7,7 +7,7 @@ import { type ChatAppearance, type TimestampOptions, type CodeBlockOptions, type
7
7
  * Der Verlauf: eigener Scroll-Container mit Autoscroll, solange man unten ist,
8
8
  * und einem Zum-Ende-Knopf, sobald man liest. Ohne Eingabe read-only nutzbar.
9
9
  */
10
- export declare function ChatMessages({ messages, owner, detailMode, running, working, stepsExpandable, texts, toolArgumentsText, renderTool, renderAction, onDismissAction, onLinkClick, emptyState, className, scrollerClassName, showTimestamps, announce, scrollerRef, maxWidth, horizontalPadding, jumpToEndThreshold, appearance, timestampOptions, codeBlockOptions, bubbleOptions, messageActions, }: {
10
+ export declare function ChatMessages({ messages, owner, detailMode, running, working, stepsExpandable, texts, toolArgumentsText, renderTool, renderAction, onDismissAction, onLinkClick, emptyState, className, scrollerClassName, showTimestamps, announce, scrollerRef, maxWidth, horizontalPadding, jumpToEndThreshold, appearance, timestampOptions, codeBlockOptions, bubbleOptions, messageActions, toolElapsedThreshold, }: {
11
11
  messages: Message[];
12
12
  /** Beiträge mit diesem Message.sender erscheinen ohne Sprechblase; null behält die Nachrichtenvorgaben. */
13
13
  owner?: string | null;
@@ -43,4 +43,6 @@ export declare function ChatMessages({ messages, owner, detailMode, running, wor
43
43
  codeBlockOptions?: CodeBlockOptions;
44
44
  bubbleOptions?: BubbleOptions;
45
45
  messageActions?: MessageActionsOptions;
46
+ /** Milliseconds before a running tool call shows its elapsed time (from Message.at) while running; false = never. */
47
+ toolElapsedThreshold?: number | false;
46
48
  }): import("react").JSX.Element;
package/README.md CHANGED
@@ -12,12 +12,15 @@ color, radius, font and font size is a `--qsl-*` variable.
12
12
 
13
13
  **Agent chats that show their work.** Thinking and tool calls appear as steps. Pick how much
14
14
  of them the user sees - only the current step, icons, chips, a collapsible group, single lines
15
- or everything - and let them switch at runtime.
15
+ or everything - and let them switch at runtime. A tool call that runs longer than a few seconds
16
+ shows how long it has been running, on its row and on the collapsed group header, so a slow step
17
+ does not look like a hung one.
16
18
 
17
19
  ![The same steps as icons, chips and single lines](https://raw.githubusercontent.com/SchlenkR/quassel/main/docs/images/details.png)
18
20
 
19
21
  **Pending actions.** The agent waits for input: a generic card with dismiss, or the host's own
20
- rendering per action owner. Resolved actions stay in the transcript as a record.
22
+ rendering per action owner. Open actions sit right above the input, like a question in a terminal
23
+ agent; resolved actions stay in the transcript as a record.
21
24
 
22
25
  **Multi-party conversations.** Colored bubbles with labels for agents that talk to each other.
23
26
 
@@ -89,6 +92,7 @@ Any backend that emits the `quassel/events` stream works with the same component
89
92
  pnpm install
90
93
  pnpm dev # stylesheet in watch mode plus the gallery on http://localhost:3210
91
94
  pnpm check # type check package and gallery
95
+ pnpm test # package tests (node:test with happy-dom)
92
96
  pnpm build # stylesheet and gallery build
93
97
  pnpm release --dry-run
94
98
  ```
package/chat.css CHANGED
@@ -87,6 +87,9 @@
87
87
  .qsl\:z-\[110\] {
88
88
  z-index: 110;
89
89
  }
90
+ .qsl\:-mx-\[16px\] {
91
+ margin-inline: calc(16px * -1);
92
+ }
90
93
  .qsl\:mx-auto {
91
94
  margin-inline: auto;
92
95
  }
@@ -200,6 +203,9 @@
200
203
  .qsl\:max-h-\[240px\] {
201
204
  max-height: 240px;
202
205
  }
206
+ .qsl\:max-h-\[calc\(var\(--qsl-panel-height\,100dvh\)_\/_2\)\] {
207
+ max-height: calc(var(--qsl-panel-height,100dvh) / 2);
208
+ }
203
209
  .qsl\:max-h-\[min\(70vh\,620px\)\] {
204
210
  max-height: min(70vh, 620px);
205
211
  }
@@ -377,6 +383,9 @@
377
383
  .qsl\:gap-\[5px\] {
378
384
  gap: 5px;
379
385
  }
386
+ .qsl\:gap-\[var\(--qsl-chat-dense-message-gap\,8px\)\] {
387
+ gap: var(--qsl-chat-dense-message-gap,8px);
388
+ }
380
389
  :where(.qsl\:space-y-0 > :not(:last-child)) {
381
390
  --tw-space-y-reverse: 0;
382
391
  margin-block-start: 0;
@@ -523,6 +532,9 @@
523
532
  .qsl\:bg-\[linear-gradient\(to_right\,var\(--qsl-scroll-cover\,var\(--qsl-background\)\)_50\%\,transparent\)_left\/20px_100\%_no-repeat_local\,linear-gradient\(to_left\,var\(--qsl-scroll-cover\,var\(--qsl-background\)\)_50\%\,transparent\)_right\/20px_100\%_no-repeat_local\,linear-gradient\(to_right\,\#00000047\,transparent\)_left\/14px_100\%_no-repeat_scroll\,linear-gradient\(to_left\,\#00000047\,transparent\)_right\/14px_100\%_no-repeat_scroll\] {
524
533
  background-image: linear-gradient(to right,var(--qsl-scroll-cover,var(--qsl-background)) 50%,transparent) left/20px 100% no-repeat local,linear-gradient(to left,var(--qsl-scroll-cover,var(--qsl-background)) 50%,transparent) right/20px 100% no-repeat local,linear-gradient(to right,#00000047,transparent) left/14px 100% no-repeat scroll,linear-gradient(to left,#00000047,transparent) right/14px 100% no-repeat scroll;
525
534
  }
535
+ .qsl\:\[mask-image\:linear-gradient\(to_bottom\,\#000_calc\(100\%_-_var\(--qsl-chat-message-gap\,16px\)\)\,transparent\)\] {
536
+ mask-image: linear-gradient(to bottom,#000 calc(100% - var(--qsl-chat-message-gap,16px)),transparent);
537
+ }
526
538
  .qsl\:\[mask-image\:linear-gradient\(to_bottom\,\#000_calc\(var\(--qsl-fade-edge\)_-_40px\)\,transparent_calc\(var\(--qsl-fade-edge\)_\+_16px\)\)\] {
527
539
  mask-image: linear-gradient(to bottom,#000 calc(var(--qsl-fade-edge) - 40px),transparent calc(var(--qsl-fade-edge) + 16px));
528
540
  }
@@ -571,6 +583,9 @@
571
583
  .qsl\:px-\[7px\] {
572
584
  padding-inline: 7px;
573
585
  }
586
+ .qsl\:px-\[16px\] {
587
+ padding-inline: 16px;
588
+ }
574
589
  .qsl\:px-\[var\(--qsl-chat-horizontal-padding\,24px\)\] {
575
590
  padding-inline: var(--qsl-chat-horizontal-padding,24px);
576
591
  }
@@ -622,6 +637,9 @@
622
637
  .qsl\:pb-\[calc\(var\(--qsl-composer-height\,0px\)_\+_max\(3\.25em\,40px\)\)\] {
623
638
  padding-bottom: calc(var(--qsl-composer-height,0px) + max(3.25em, 40px));
624
639
  }
640
+ .qsl\:pb-\[var\(--qsl-chat-message-gap\,16px\)\] {
641
+ padding-bottom: var(--qsl-chat-message-gap,16px);
642
+ }
625
643
  .qsl\:pl-1 {
626
644
  padding-left: var(--qsl-spacing);
627
645
  }
@@ -916,6 +934,9 @@
916
934
  .qsl\:\[--qsl-scroll-cover\:var\(--qsl-secondary\)\] {
917
935
  --qsl-scroll-cover: var(--qsl-secondary);
918
936
  }
937
+ .qsl\:\[-webkit-mask-image\:linear-gradient\(to_bottom\,\#000_calc\(100\%_-_var\(--qsl-chat-message-gap\,16px\)\)\,transparent\)\] {
938
+ -webkit-mask-image: linear-gradient(to bottom,#000 calc(100% - var(--qsl-chat-message-gap,16px)),transparent);
939
+ }
919
940
  .qsl\:\[-webkit-mask-image\:linear-gradient\(to_bottom\,\#000_calc\(var\(--qsl-fade-edge\)_-_40px\)\,transparent_calc\(var\(--qsl-fade-edge\)_\+_16px\)\)\] {
920
941
  -webkit-mask-image: linear-gradient(to bottom,#000 calc(var(--qsl-fade-edge) - 40px),transparent calc(var(--qsl-fade-edge) + 16px));
921
942
  }
@@ -951,6 +972,9 @@
951
972
  .qsl\:placeholder\:text-muted-foreground::placeholder {
952
973
  color: var(--qsl-muted-foreground);
953
974
  }
975
+ .qsl\:empty\:hidden:empty {
976
+ display: none;
977
+ }
954
978
  .qsl\:focus-within\:pointer-events-auto:focus-within {
955
979
  pointer-events: auto;
956
980
  }
package/elapsed.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { type ChatTexts } from "./texts";
2
+ /** Under a minute "{seconds} s" from texts, then m:ss, from an hour on h:mm:ss. */
3
+ export declare function formatElapsed(ms: number, texts: Pick<ChatTexts, "toolElapsedSeconds">): string;
4
+ /** One interval for every subscriber; it runs only while someone listens. */
5
+ export declare function createTicker(intervalMs?: number): {
6
+ subscribe(listener: () => void): () => void;
7
+ now(): number;
8
+ active: () => boolean;
9
+ };
10
+ export declare const elapsedTicker: {
11
+ subscribe(listener: () => void): () => void;
12
+ now(): number;
13
+ active: () => boolean;
14
+ };
15
+ /** Milliseconds since start in whole seconds, so a row renders only when its display changes; undefined on the server. */
16
+ export declare function useElapsed(start: number): number | undefined;
package/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export { useChatAction, copyChatText } from "./useChatAction";
17
17
  export { createChatScroll } from "./chat-scroll";
18
18
  export { ChatSendContext, createChatSendScope, type ChatSendScope } from "./ChatSendContext";
19
19
  export { messageDate, timestampDay, timestampLabel } from "./timestamps";
20
+ export { formatElapsed, useElapsed } from "./elapsed";
20
21
  export { attachmentCapabilityError, attachmentDownloadUrl, attachmentMediaType, encodeAttachment, formatAttachmentSize, validateAttachmentSelection, MAX_CHAT_ATTACHMENTS, MAX_CHAT_ATTACHMENT_BYTES, } from "./attachments";
21
22
  export type { ChatAppearance, TimestampOptions, CodeBlockOptions, BubbleOptions, MessageAction, MessageActionsOptions, SendShortcut, } from "./options";
22
23
  export { appearanceStyle } from "./options";
package/index.js CHANGED
@@ -24,6 +24,7 @@ var defaultTexts = {
24
24
  working: "Arbeitet ...",
25
25
  toolRunning: "l\xE4uft ...",
26
26
  toolStillRunning: "Werkzeug l\xE4uft ...",
27
+ toolElapsedSeconds: "{seconds} s",
27
28
  currentTool: "Werkzeug l\xE4uft",
28
29
  thinkingChip: "Denken",
29
30
  toolChip: "Werkzeug",
@@ -90,6 +91,7 @@ var cn = createCn({ prefix: "qsl", extend: { theme: { text: ["meta", "trace", "l
90
91
 
91
92
  // packages/quassel/src/ChatMessages.tsx
92
93
  import { useCallback as useCallback2, useContext as useContext3, useEffect as useEffect3, useLayoutEffect, useMemo as useMemo3, useRef as useRef3, useState as useState4 } from "react";
94
+ import { createPortal } from "react-dom";
93
95
 
94
96
  // packages/quassel/src/chat-scroll.ts
95
97
  var atEnd = (viewport) => viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <= 24;
@@ -887,6 +889,53 @@ function timestampDay(date, options = {}) {
887
889
  return { key, label };
888
890
  }
889
891
 
892
+ // packages/quassel/src/elapsed.ts
893
+ import { useSyncExternalStore } from "react";
894
+ var tickMs = 500;
895
+ function formatElapsed(ms, texts) {
896
+ const total = Math.max(0, Math.floor(ms / 1e3));
897
+ if (total < 60) return fillText(texts.toolElapsedSeconds, { seconds: total });
898
+ const hours = Math.floor(total / 3600);
899
+ const minutes = Math.floor(total / 60) % 60;
900
+ const seconds = String(total % 60).padStart(2, "0");
901
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
902
+ }
903
+ function createTicker(intervalMs = tickMs) {
904
+ const listeners = /* @__PURE__ */ new Set();
905
+ let now = Date.now();
906
+ let timer;
907
+ return {
908
+ subscribe(listener) {
909
+ listeners.add(listener);
910
+ if (timer === void 0) {
911
+ now = Date.now();
912
+ timer = setInterval(() => {
913
+ now = Date.now();
914
+ listeners.forEach((notify) => notify());
915
+ }, intervalMs);
916
+ }
917
+ return () => {
918
+ listeners.delete(listener);
919
+ if (listeners.size === 0 && timer !== void 0) {
920
+ clearInterval(timer);
921
+ timer = void 0;
922
+ }
923
+ };
924
+ },
925
+ now() {
926
+ const current = Date.now();
927
+ if (timer === void 0 && (current - now >= intervalMs || current < now)) now = current;
928
+ return now;
929
+ },
930
+ active: () => timer !== void 0
931
+ };
932
+ }
933
+ var elapsedTicker = createTicker();
934
+ var nothingOnServer = () => void 0;
935
+ function useElapsed(start) {
936
+ return useSyncExternalStore(elapsedTicker.subscribe, () => Math.floor((elapsedTicker.now() - start) / 1e3) * 1e3, nothingOnServer);
937
+ }
938
+
890
939
  // packages/quassel/src/MessageActions.tsx
891
940
  import { CheckIcon as CheckIcon3, CopyIcon as CopyIcon2, PencilIcon, RotateCcwIcon } from "lucide-react";
892
941
  import { useState as useState3 } from "react";
@@ -952,6 +1001,10 @@ function createChatSendScope(enabled) {
952
1001
  };
953
1002
  }
954
1003
 
1004
+ // packages/quassel/src/ChatActionDock.ts
1005
+ import { createContext as createContext4 } from "react";
1006
+ var ChatActionDock = createContext4(void 0);
1007
+
955
1008
  // packages/quassel/src/announce.ts
956
1009
  var abstandMs = 400;
957
1010
  var hoechstensWartend = 5;
@@ -1035,6 +1088,9 @@ var bubbleBodyClasses = "qsl:relative qsl:max-w-[86%] qsl:px-3 qsl:py-2 qsl:lead
1035
1088
  function istSchritt(message) {
1036
1089
  return !!message && (message.role === "thinking" || message.role === "tool");
1037
1090
  }
1091
+ function istOffeneAktion(message) {
1092
+ return message.role === "action" && message.action !== void 0 && message.action.status === void 0;
1093
+ }
1038
1094
  function defaultArgumentsText(tool) {
1039
1095
  return prettyJson(tool.arguments);
1040
1096
  }
@@ -1064,7 +1120,8 @@ function ChatMessages({
1064
1120
  timestampOptions,
1065
1121
  codeBlockOptions,
1066
1122
  bubbleOptions,
1067
- messageActions
1123
+ messageActions,
1124
+ toolElapsedThreshold = 3e3
1068
1125
  }) {
1069
1126
  const alleTexte = { ...defaultTexts, ...texts };
1070
1127
  const { Button: Button2 } = useQuasselComponents();
@@ -1075,6 +1132,7 @@ function ChatMessages({
1075
1132
  const [showJumpToEnd, setShowJumpToEnd] = useState4(false);
1076
1133
  const [scroll] = useState4(() => createChatScroll());
1077
1134
  const sendScope = useContext3(ChatSendContext);
1135
+ const dock = useContext3(ChatActionDock);
1078
1136
  const [now, setNow] = useState4(() => Date.now());
1079
1137
  useEffect3(() => {
1080
1138
  if (!showTimestamps || timestampOptions?.format !== "relative") return;
@@ -1084,7 +1142,7 @@ function ChatMessages({
1084
1142
  }, [showTimestamps, timestampOptions?.format]);
1085
1143
  const angesagt = useRef3(new Set(messages.map((message) => message.key)));
1086
1144
  useEffect3(() => {
1087
- const faellig = messages.filter((message, index) => !angesagt.current.has(message.key) && (message.role === "action" ? message.action !== void 0 && message.action.status === void 0 : message.role === "assistant" && message.text.trim() !== "" && (message.closed === true || !running && index === messages.length - 1)));
1145
+ const faellig = messages.filter((message, index) => !angesagt.current.has(message.key) && (message.role === "action" ? istOffeneAktion(message) : message.role === "assistant" && message.text.trim() !== "" && (message.closed === true || !running && index === messages.length - 1)));
1088
1146
  if (faellig.length === 0) {
1089
1147
  return;
1090
1148
  }
@@ -1147,6 +1205,9 @@ function ChatMessages({
1147
1205
  () => {
1148
1206
  const letzterFremder = messages.reduce((last, message, index) => message.role === "user" ? last : index, -1);
1149
1207
  return messages.filter((message, index) => {
1208
+ if (dock !== void 0 && istOffeneAktion(message)) {
1209
+ return false;
1210
+ }
1150
1211
  if (detailMode === "current" && istSchritt(message)) {
1151
1212
  return running && index === letzterFremder && (message.role === "thinking" ? !message.closed : stepState(message) === "running");
1152
1213
  }
@@ -1159,8 +1220,9 @@ function ChatMessages({
1159
1220
  return true;
1160
1221
  });
1161
1222
  },
1162
- [messages, detailMode, running]
1223
+ [messages, detailMode, running, dock]
1163
1224
  );
1225
+ const offeneAktionen = dock ? messages.filter(istOffeneAktion) : [];
1164
1226
  const mitZeit = (inhalt, key, at, dicht = false) => showTimestamps ? /* @__PURE__ */ jsxs6("div", { className: cn("qsl:flex qsl:items-baseline qsl:gap-2", dicht ? denseStepClasses : stepClasses), children: [
1165
1227
  /* @__PURE__ */ jsx13("time", { dateTime: messageDate(at)?.toISOString(), className: cn(
1166
1228
  "qsl:flex-none qsl:text-meta qsl:tabular-nums qsl:text-muted-foreground qsl:opacity-85",
@@ -1169,19 +1231,21 @@ function ChatMessages({
1169
1231
  /* @__PURE__ */ jsx13("div", { className: "qsl:min-w-0 qsl:flex-1 qsl:[&>*:first-child]:mt-0", children: inhalt })
1170
1232
  ] }, `zeit-${key}`) : inhalt;
1171
1233
  const chipModus = detailMode === "current" || detailMode === "chips" || detailMode === "icons";
1234
+ const elapsedAfter = running && toolElapsedThreshold !== false ? toolElapsedThreshold : void 0;
1172
1235
  const gruppenModus = detailMode === "grouped";
1173
1236
  const bloecke = [];
1174
1237
  let lastDay;
1175
1238
  let gruppe = [];
1176
1239
  const schliesseGruppe = () => {
1177
1240
  if (gruppe.length > 0) {
1178
- const letzterZustand = stepState(gruppe[gruppe.length - 1]);
1241
+ const laufenderSchritt = running ? [...gruppe].reverse().find((message) => ["running", "thinking"].includes(stepState(message))) : void 0;
1179
1242
  bloecke.push(mitZeit(
1180
1243
  gruppenModus ? /* @__PURE__ */ jsx13(
1181
1244
  StepGroup,
1182
1245
  {
1183
- aktiv: running && (letzterZustand === "running" || letzterZustand === "thinking"),
1246
+ elapsedAfter,
1184
1247
  expandierbar: stepsExpandable,
1248
+ laufenderSchritt,
1185
1249
  messages: gruppe,
1186
1250
  texts: alleTexte,
1187
1251
  toolArgumentsText
@@ -1190,6 +1254,7 @@ function ChatMessages({
1190
1254
  ) : /* @__PURE__ */ jsx13(
1191
1255
  StepRow,
1192
1256
  {
1257
+ elapsedAfter,
1193
1258
  expandierbar: stepsExpandable,
1194
1259
  messages: gruppe,
1195
1260
  mitText: detailMode !== "icons",
@@ -1237,6 +1302,7 @@ function ChatMessages({
1237
1302
  {
1238
1303
  detailMode,
1239
1304
  dicht,
1305
+ elapsedAfter,
1240
1306
  expandierbar: stepsExpandable,
1241
1307
  message,
1242
1308
  plain: owner != null && message.sender === owner,
@@ -1322,7 +1388,11 @@ function ChatMessages({
1322
1388
  scroll.jump(scrollBereich.current);
1323
1389
  updateJumpVisibility(scrollBereich.current);
1324
1390
  }
1325
- }, children: /* @__PURE__ */ jsx13(ChevronDownIcon, {}) }) })
1391
+ }, children: /* @__PURE__ */ jsx13(ChevronDownIcon, {}) }) }),
1392
+ dock && offeneAktionen.length > 0 && createPortal(
1393
+ /* @__PURE__ */ jsx13("div", { className: "qsl:flex qsl:flex-col qsl:gap-[var(--qsl-chat-dense-message-gap,8px)] qsl:text-[length:var(--qsl-chat-font-size,13px)] qsl:text-foreground", style: appearanceStyle(appearance), children: offeneAktionen.map((message) => /* @__PURE__ */ jsx13("div", { children: /* @__PURE__ */ jsx13(ActionContent, { action: message.action, onDismissAction, renderAction, text: message.text, texts: alleTexte }) }, message.key)) }),
1394
+ dock
1395
+ )
1326
1396
  ] });
1327
1397
  return /* @__PURE__ */ jsx13(MarkdownLinks, { onLinkClick, children: /* @__PURE__ */ jsx13(MarkdownCodeBlocks, { options: codeBlockOptions, texts: alleTexte, children: verlauf }) });
1328
1398
  }
@@ -1330,6 +1400,7 @@ function StepRow({
1330
1400
  messages,
1331
1401
  mitText,
1332
1402
  expandierbar,
1403
+ elapsedAfter,
1333
1404
  texts,
1334
1405
  toolArgumentsText,
1335
1406
  toolLabel
@@ -1346,6 +1417,7 @@ function StepRow({
1346
1417
  const content = /* @__PURE__ */ jsxs6(Fragment2, { children: [
1347
1418
  thinking ? /* @__PURE__ */ jsx13(SparklesIcon2, { size: 11 }) : /* @__PURE__ */ jsx13(WrenchIcon2, { className: tool?.isError ? "qsl:text-destructive" : void 0, size: 11 }),
1348
1419
  mitText && /* @__PURE__ */ jsx13("span", { className: "qsl:min-w-0 qsl:self-baseline qsl:overflow-hidden qsl:text-ellipsis qsl:whitespace-nowrap", children: label }),
1420
+ mitText && /* @__PURE__ */ jsx13(ToolElapsed, { after: elapsedAfter, className: "qsl:flex-none qsl:self-baseline", message, texts }),
1349
1421
  mitText && /* @__PURE__ */ jsx13("span", { className: "qsl:inline-flex qsl:w-2.5 qsl:flex-none qsl:items-center qsl:justify-center qsl:text-success", children: state === "done" && /* @__PURE__ */ jsx13(CheckIcon4, { size: 10 }) })
1350
1422
  ] });
1351
1423
  const classes = cn(chipClasses, !mitText && "qsl:p-1", running && "qsl:animate-fade-pulse qsl:motion-reduce:animate-none");
@@ -1382,7 +1454,8 @@ function StepRow({
1382
1454
  }
1383
1455
  function StepGroup({
1384
1456
  messages,
1385
- aktiv,
1457
+ laufenderSchritt,
1458
+ elapsedAfter,
1386
1459
  expandierbar,
1387
1460
  texts,
1388
1461
  toolArgumentsText
@@ -1396,10 +1469,10 @@ function StepGroup({
1396
1469
  kopf.current?.scrollIntoView({ block: "nearest" });
1397
1470
  }
1398
1471
  }, [offen]);
1399
- const letzte = messages[messages.length - 1];
1472
+ const aktiv = laufenderSchritt !== void 0;
1400
1473
  const fehler = messages.some((message) => message.tool?.isError);
1401
1474
  const anzahl = messages.length === 1 ? texts.stepGroupOne : texts.stepGroupMany.replace("{count}", String(messages.length));
1402
- const laufend = aktiv ? letzte.role === "thinking" ? texts.thinkingChip : letzte.tool?.name || letzte.text || texts.toolChip : void 0;
1475
+ const laufend = laufenderSchritt && (laufenderSchritt.role === "thinking" ? texts.thinkingChip : laufenderSchritt.tool?.name || laufenderSchritt.text || texts.toolChip);
1403
1476
  return /* @__PURE__ */ jsxs6("div", { className: stepClasses, "data-step": "group", children: [
1404
1477
  /* @__PURE__ */ jsxs6(
1405
1478
  "button",
@@ -1418,12 +1491,13 @@ function StepGroup({
1418
1491
  laufend,
1419
1492
  " ",
1420
1493
  texts.toolRunning
1421
- ] })
1494
+ ] }),
1495
+ laufenderSchritt && /* @__PURE__ */ jsx13(ToolElapsed, { after: elapsedAfter, message: laufenderSchritt, texts })
1422
1496
  ] })
1423
1497
  ]
1424
1498
  }
1425
1499
  ),
1426
- offen && /* @__PURE__ */ jsx13("div", { className: "qsl:mt-0.5 qsl:flex qsl:flex-col qsl:gap-0.5 qsl:pl-3", children: messages.map((message) => /* @__PURE__ */ jsx13(TraceLine, { className: "qsl:pl-3", expandierbar, message, texts, toolArgumentsText }, message.key)) }),
1500
+ offen && /* @__PURE__ */ jsx13("div", { className: "qsl:mt-0.5 qsl:flex qsl:flex-col qsl:gap-0.5 qsl:pl-3", children: messages.map((message) => /* @__PURE__ */ jsx13(TraceLine, { className: "qsl:pl-3", elapsedAfter, expandierbar, message, texts, toolArgumentsText }, message.key)) }),
1427
1501
  offen && messages.length >= collapseAtBottomFrom && /* @__PURE__ */ jsxs6(
1428
1502
  "button",
1429
1503
  {
@@ -1445,6 +1519,7 @@ function StepGroup({
1445
1519
  function TraceLine({
1446
1520
  message,
1447
1521
  className,
1522
+ elapsedAfter,
1448
1523
  expandierbar,
1449
1524
  texts,
1450
1525
  toolArgumentsText
@@ -1458,7 +1533,8 @@ function TraceLine({
1458
1533
  thinking ? /* @__PURE__ */ jsx13(SparklesIcon2, { className: traceIconClasses, size: 12 }) : /* @__PURE__ */ jsx13(WrenchIcon2, { className: tool?.isError ? traceErrorIconClasses : traceIconClasses, size: 12 }),
1459
1534
  /* @__PURE__ */ jsxs6("span", { className: zeile, children: [
1460
1535
  message.text,
1461
- tool && tool.result === void 0 && /* @__PURE__ */ jsx13("span", { className: traceRunningClasses, children: texts.toolRunning })
1536
+ tool && tool.result === void 0 && /* @__PURE__ */ jsx13("span", { className: traceRunningClasses, children: texts.toolRunning }),
1537
+ /* @__PURE__ */ jsx13(ToolElapsed, { after: elapsedAfter, message, texts })
1462
1538
  ] })
1463
1539
  ] });
1464
1540
  const marks = { "data-compact": "true", "data-kind": thinking ? "thinking" : "tool", "data-step": "line" };
@@ -1490,11 +1566,21 @@ function TraceLine({
1490
1566
  )
1491
1567
  ] });
1492
1568
  }
1569
+ function ToolElapsed({ message, after, texts, className = traceRunningClasses }) {
1570
+ const start = stepState(message) === "running" ? messageDate(message.at)?.getTime() : void 0;
1571
+ return after === void 0 || start === void 0 ? null : /* @__PURE__ */ jsx13(Elapsed, { after, className, start, texts });
1572
+ }
1573
+ function Elapsed({ start, after, className, texts }) {
1574
+ const elapsed = useElapsed(start);
1575
+ if (elapsed === void 0 || elapsed < after) return null;
1576
+ return /* @__PURE__ */ jsx13("span", { "aria-hidden": "true", className: cn(className, "qsl:tabular-nums"), "data-step": "elapsed", children: formatElapsed(elapsed, texts) });
1577
+ }
1493
1578
  function Bubble({
1494
1579
  message,
1495
1580
  plain,
1496
1581
  detailMode,
1497
1582
  dicht,
1583
+ elapsedAfter,
1498
1584
  expandierbar,
1499
1585
  texts,
1500
1586
  toolArgumentsText,
@@ -1506,14 +1592,15 @@ function Bubble({
1506
1592
  const schritt = dicht ? denseStepClasses : stepClasses;
1507
1593
  if (message.role === "thinking" || message.role === "tool" && message.tool) {
1508
1594
  if (detailMode === "compact") {
1509
- return /* @__PURE__ */ jsx13(TraceLine, { className: schritt, expandierbar, message, texts, toolArgumentsText });
1595
+ return /* @__PURE__ */ jsx13(TraceLine, { className: schritt, elapsedAfter, expandierbar, message, texts, toolArgumentsText });
1510
1596
  }
1511
1597
  const thinking = message.role === "thinking";
1512
1598
  const tool = message.tool;
1513
1599
  const icon = thinking ? /* @__PURE__ */ jsx13(SparklesIcon2, { className: traceIconClasses, size: 12 }) : /* @__PURE__ */ jsx13(WrenchIcon2, { className: tool?.isError ? traceErrorIconClasses : traceIconClasses, size: 12 });
1514
1600
  const zeile = /* @__PURE__ */ jsxs6(Fragment2, { children: [
1515
1601
  message.text,
1516
- tool && tool.result === void 0 && /* @__PURE__ */ jsx13("span", { className: traceRunningClasses, children: texts.toolRunning })
1602
+ tool && tool.result === void 0 && /* @__PURE__ */ jsx13("span", { className: traceRunningClasses, children: texts.toolRunning }),
1603
+ /* @__PURE__ */ jsx13(ToolElapsed, { after: elapsedAfter, message, texts })
1517
1604
  ] });
1518
1605
  return /* @__PURE__ */ jsxs6("div", { className: cn(traceClasses, schritt), "data-kind": thinking ? "thinking" : "tool", "data-step": "detail", children: [
1519
1606
  icon,
@@ -1530,18 +1617,7 @@ function Bubble({
1530
1617
  ] });
1531
1618
  }
1532
1619
  if (message.role === "action" && message.action) {
1533
- const action = message.action;
1534
- return /* @__PURE__ */ jsx13("div", { className: schritt, children: renderAction?.(action, message.text) ?? /* @__PURE__ */ jsx13(
1535
- PendingActionCard,
1536
- {
1537
- action,
1538
- dismissedLabel: texts.actionDismissed,
1539
- dismissLabel: texts.dismissAction,
1540
- onDismiss: onDismissAction ? () => onDismissAction(action.actionId) : void 0,
1541
- text: message.text,
1542
- waitingLabel: texts.pendingAction
1543
- }
1544
- ) });
1620
+ return /* @__PURE__ */ jsx13("div", { className: schritt, children: /* @__PURE__ */ jsx13(ActionContent, { action: message.action, onDismissAction, renderAction, text: message.text, texts }) });
1545
1621
  }
1546
1622
  if (message.role === "system") {
1547
1623
  return /* @__PURE__ */ jsxs6("div", { className: cn(schritt, "qsl:border-l-2 qsl:border-destructive/50 qsl:pl-3 qsl:text-sm qsl:text-muted-foreground"), "data-message": "system", children: [
@@ -1586,6 +1662,25 @@ function Bubble({
1586
1662
  }
1587
1663
  );
1588
1664
  }
1665
+ function ActionContent({
1666
+ action,
1667
+ text,
1668
+ texts,
1669
+ renderAction,
1670
+ onDismissAction
1671
+ }) {
1672
+ return renderAction?.(action, text) ?? /* @__PURE__ */ jsx13(
1673
+ PendingActionCard,
1674
+ {
1675
+ action,
1676
+ dismissedLabel: texts.actionDismissed,
1677
+ dismissLabel: texts.dismissAction,
1678
+ onDismiss: onDismissAction ? () => onDismissAction(action.actionId) : void 0,
1679
+ text,
1680
+ waitingLabel: texts.pendingAction
1681
+ }
1682
+ );
1683
+ }
1589
1684
  function MessageAttachments({ message, label }) {
1590
1685
  if (!message.attachments?.length) return null;
1591
1686
  return /* @__PURE__ */ jsx13("ul", { className: "qsl:mt-2.5 qsl:flex qsl:flex-wrap qsl:gap-2", "aria-label": label, children: message.attachments.map((attachment, index) => /* @__PURE__ */ jsxs6("li", { className: "qsl:relative qsl:flex qsl:w-[190px] qsl:max-w-full qsl:min-w-0 qsl:flex-col qsl:overflow-hidden qsl:rounded-lg qsl:border qsl:border-border qsl:text-sm", children: [
@@ -1613,6 +1708,7 @@ function ChatPanel({
1613
1708
  }) {
1614
1709
  const wurzel = useRef4(null);
1615
1710
  const eingabe = useRef4(null);
1711
+ const [dock, setDock] = useState5(null);
1616
1712
  const hasComposer = composer !== void 0;
1617
1713
  const scrollOnSendRef = useRef4(scrollOnSend);
1618
1714
  const [sendScope] = useState5(() => createChatSendScope(scrollOnSendRef));
@@ -1625,16 +1721,21 @@ function ChatPanel({
1625
1721
  if (!element || !rahmen) {
1626
1722
  return;
1627
1723
  }
1628
- const messen = () => rahmen.style.setProperty("--qsl-composer-height", `${element.offsetHeight}px`);
1724
+ const messen = () => {
1725
+ rahmen.style.setProperty("--qsl-composer-height", `${element.offsetHeight}px`);
1726
+ rahmen.style.setProperty("--qsl-panel-height", `${rahmen.offsetHeight}px`);
1727
+ };
1629
1728
  const beobachter = new ResizeObserver(messen);
1630
1729
  beobachter.observe(element);
1730
+ beobachter.observe(rahmen);
1631
1731
  messen();
1632
1732
  return () => {
1633
1733
  beobachter.disconnect();
1634
1734
  rahmen.style.removeProperty("--qsl-composer-height");
1735
+ rahmen.style.removeProperty("--qsl-panel-height");
1635
1736
  };
1636
1737
  }, [hasComposer]);
1637
- return /* @__PURE__ */ jsx14(ChatSendContext.Provider, { value: sendScope, children: /* @__PURE__ */ jsxs7(
1738
+ return /* @__PURE__ */ jsx14(ChatSendContext.Provider, { value: sendScope, children: /* @__PURE__ */ jsx14(ChatActionDock.Provider, { value: hasComposer ? dock : void 0, children: /* @__PURE__ */ jsxs7(
1638
1739
  "div",
1639
1740
  {
1640
1741
  className: cn("qsl:relative qsl:flex qsl:min-h-0 qsl:min-w-0 qsl:flex-col qsl:overflow-hidden", className),
@@ -1655,10 +1756,20 @@ function ChatPanel({
1655
1756
  },
1656
1757
  children: [
1657
1758
  children,
1658
- hasComposer && /* @__PURE__ */ jsx14("div", { className: "qsl:absolute qsl:inset-x-0 qsl:bottom-0 qsl:z-[5] qsl:px-[var(--qsl-chat-horizontal-padding,24px)] qsl:pb-3.5 qsl:*:mx-auto qsl:*:max-w-[var(--qsl-chat-content-max-width,none)]", "data-chat": "composer", ref: eingabe, children: composer })
1759
+ hasComposer && /* @__PURE__ */ jsxs7("div", { className: "qsl:absolute qsl:inset-x-0 qsl:bottom-0 qsl:z-[5] qsl:px-[var(--qsl-chat-horizontal-padding,24px)] qsl:pb-3.5 qsl:*:mx-auto qsl:*:max-w-[var(--qsl-chat-content-max-width,none)]", "data-chat": "composer", ref: eingabe, children: [
1760
+ /* @__PURE__ */ jsx14("div", { children: /* @__PURE__ */ jsx14(
1761
+ "div",
1762
+ {
1763
+ className: "qsl:-mx-[16px] qsl:max-h-[calc(var(--qsl-panel-height,100dvh)_/_2)] qsl:overflow-y-auto qsl:px-[16px] qsl:pb-[var(--qsl-chat-message-gap,16px)] qsl:[-webkit-mask-image:linear-gradient(to_bottom,#000_calc(100%_-_var(--qsl-chat-message-gap,16px)),transparent)] qsl:[mask-image:linear-gradient(to_bottom,#000_calc(100%_-_var(--qsl-chat-message-gap,16px)),transparent)] qsl:empty:hidden",
1764
+ "data-chat": "actions",
1765
+ ref: setDock
1766
+ }
1767
+ ) }),
1768
+ composer
1769
+ ] })
1659
1770
  ]
1660
1771
  }
1661
- ) });
1772
+ ) }) });
1662
1773
  }
1663
1774
 
1664
1775
  // packages/quassel/src/ChatInputToolbar.tsx
@@ -1714,7 +1825,7 @@ function useFileInput({ disabled, onFiles, onPasteText }) {
1714
1825
 
1715
1826
  // packages/quassel/src/ChatInputToolbar.tsx
1716
1827
  import { useCallback as useCallback3, useContext as useContext4, useEffect as useEffect5, useImperativeHandle, useLayoutEffect as useLayoutEffect3, useRef as useRef6, useState as useState7 } from "react";
1717
- import { createPortal } from "react-dom";
1828
+ import { createPortal as createPortal2 } from "react-dom";
1718
1829
  import { Fragment as Fragment3, jsx as jsx15, jsxs as jsxs8 } from "react/jsx-runtime";
1719
1830
  var COMPACT_WIDTH_PX = 480;
1720
1831
  var errorClasses = "qsl:px-3 qsl:py-2 qsl:text-sm qsl:text-destructive";
@@ -2184,7 +2295,7 @@ function ChatInputToolbar({
2184
2295
  ] }) : input,
2185
2296
  toolbar ? /* @__PURE__ */ jsxs8(Fragment3, { children: [
2186
2297
  /* @__PURE__ */ jsx15("div", { className: "qsl:flex qsl:flex-none qsl:items-center qsl:gap-1 qsl:p-1", children: sendButtons }),
2187
- detailsContainer && createPortal(details, detailsContainer)
2298
+ detailsContainer && createPortal2(details, detailsContainer)
2188
2299
  ] }) : !inline && /* @__PURE__ */ jsxs8(Fragment3, { children: [
2189
2300
  errors,
2190
2301
  detailToolbar
@@ -2295,6 +2406,7 @@ export {
2295
2406
  encodeAttachment,
2296
2407
  fillText,
2297
2408
  formatAttachmentSize,
2409
+ formatElapsed,
2298
2410
  markdownPlainText,
2299
2411
  messageDate,
2300
2412
  prettyJson,
@@ -2302,6 +2414,7 @@ export {
2302
2414
  timestampDay,
2303
2415
  timestampLabel,
2304
2416
  useChatAction,
2417
+ useElapsed,
2305
2418
  useQuasselComponents,
2306
2419
  useQuasselPortalContainer,
2307
2420
  validateAttachmentSelection