@parallelworks/ui 0.26.0 → 0.28.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.
Files changed (34) hide show
  1. package/dist/ai/adapter/types.d.ts +5 -0
  2. package/dist/ai/ai.css +197 -21
  3. package/dist/ai/components/AllocationSelector.js +2 -2
  4. package/dist/ai/components/BranchNavigator.js +6 -6
  5. package/dist/ai/components/ChatEmptyState.js +39 -36
  6. package/dist/ai/components/ChatInput.js +67 -60
  7. package/dist/ai/components/ChatMessage.js +21 -20
  8. package/dist/ai/components/ChatMessageList.d.ts +7 -2
  9. package/dist/ai/components/ChatMessageList.js +65 -54
  10. package/dist/ai/components/ChatSidebar.js +6 -6
  11. package/dist/ai/components/ChatThread.js +3 -63
  12. package/dist/ai/components/ModelSelector.js +7 -7
  13. package/dist/ai/components/PastedText.d.ts +3 -1
  14. package/dist/ai/components/PastedText.js +21 -6
  15. package/dist/ai/components/Reasoning.d.ts +17 -0
  16. package/dist/ai/components/Reasoning.js +29 -0
  17. package/dist/ai/components/agent/AgentMessageParts.js +32 -22
  18. package/dist/ai/core/ChatProvider.js +3 -1
  19. package/dist/ai/core/parts.js +20 -0
  20. package/dist/ai/core/pasteLanguage.d.ts +6 -0
  21. package/dist/ai/core/pasteLanguage.js +45 -0
  22. package/dist/ai/index.d.ts +1 -1
  23. package/dist/ai/index.js +2 -2
  24. package/dist/ai/strings.d.ts +9 -0
  25. package/dist/ai/strings.js +7 -1
  26. package/dist/ai/ui/Notice.d.ts +8 -0
  27. package/dist/ai/ui/Notice.js +27 -1
  28. package/dist/ai/ui/sidebar.js +3 -3
  29. package/dist/file-explorer/FileExplorer.js +34 -11
  30. package/dist/file-explorer/FilePreview/previewCache.d.ts +1 -1
  31. package/dist/file-explorer/FilePreview/previewCache.js +6 -4
  32. package/dist/file-explorer/lib/types.d.ts +1 -0
  33. package/dist/styles.css +1 -1
  34. package/package.json +1 -1
@@ -3,6 +3,8 @@ import { FiX } from "../../icons/fi/FiX.js";
3
3
  import { ImSpinner5 } from "../../icons/im/ImSpinner5.js";
4
4
  import { useChatConfig } from "../core/config.js";
5
5
  import { formatFileSize } from "../utils.js";
6
+ import Markdown from "../ui/Markdown.js";
7
+ import { fencePaste } from "../core/pasteLanguage.js";
6
8
  import { splitPastes } from "../core/pastes.js";
7
9
  import cx from "classnames";
8
10
  import { Fragment } from "react";
@@ -14,7 +16,7 @@ function PasteFileCard({ id, lines, bytes, saving = false, error, onRemove }) {
14
16
  const detail = error ?? (saving ? t.saving : `${t.lines(lines)} · ${formatFileSize(bytes)}`);
15
17
  return /* @__PURE__ */ jsxs("div", {
16
18
  "data-testid": "paste-file-card",
17
- className: cx("inline-flex max-w-full items-center gap-2.5 rounded-xl border px-3 py-2 align-top whitespace-normal", "bg-(--theme-muted-panel-bg)", error ? "border-red-500" : "theme-border"),
19
+ className: cx("inline-flex max-w-full items-center gap-2.5 rounded-xl px-3 py-2 align-top whitespace-normal", "bg-(--theme-panel-bg)", error ? "border border-red-500" : "chat-hairline"),
18
20
  children: [
19
21
  /* @__PURE__ */ jsx(FiFile, { className: "h-5 w-5 flex-shrink-0 theme-muted-text" }),
20
22
  /* @__PURE__ */ jsxs("div", {
@@ -39,14 +41,27 @@ function PasteFileCard({ id, lines, bytes, saving = false, error, onRemove }) {
39
41
  ]
40
42
  });
41
43
  }
42
- /** A user message as typed: an inline paste as its text, one sent as its file as a card. */
44
+ /** A user message as typed: an inline paste as a code block in the language
45
+ * it reads as, one sent as its file as a card. The blank lines the composer puts around a
46
+ * paste would stack on the block's own spacing, so they are dropped. */
43
47
  function UserMessageText({ content, pastes }) {
44
48
  if (!pastes?.length) return /* @__PURE__ */ jsx(Fragment$1, { children: content });
45
- return /* @__PURE__ */ jsx(Fragment$1, { children: splitPastes(content, pastes).map((segment, i) => {
46
- if (typeof segment === "string") return /* @__PURE__ */ jsx(Fragment, { children: segment }, i);
47
- if (segment.inline && segment.text !== void 0) return /* @__PURE__ */ jsx(Fragment, { children: segment.text }, i);
49
+ const segments = splitPastes(content, pastes);
50
+ const shows = (seg) => typeof seg !== "string" || seg.trim() !== "";
51
+ const blockSpacing = (i) => cx(segments.slice(0, i).some(shows) ? "mt-2.5" : "mt-0", segments.slice(i + 1).some(shows) ? "mb-2.5" : "mb-0");
52
+ return /* @__PURE__ */ jsx(Fragment$1, { children: segments.map((segment, i) => {
53
+ if (typeof segment === "string") {
54
+ let text = segment;
55
+ if (i > 0) text = text.replace(/^\n+/, "");
56
+ if (i < segments.length - 1) text = text.replace(/\n+$/, "");
57
+ return text ? /* @__PURE__ */ jsx(Fragment, { children: text }, i) : null;
58
+ }
59
+ if (segment.inline && segment.text !== void 0) return /* @__PURE__ */ jsx("span", {
60
+ className: cx("chat-paste-code block", blockSpacing(i)),
61
+ children: /* @__PURE__ */ jsx(Markdown, { children: fencePaste(segment.text) })
62
+ }, i);
48
63
  return /* @__PURE__ */ jsx("span", {
49
- className: "my-1 block",
64
+ className: cx("flex [&>*]:flex-1", blockSpacing(i)),
50
65
  children: /* @__PURE__ */ jsx(PasteFileCard, {
51
66
  id: segment.id,
52
67
  lines: segment.lines,
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from 'react';
2
+ /** The line a turn's reasoning folds under. It opens the reasoning in place,
3
+ * above the answer it led to, rather than in a panel the eye has to cross
4
+ * the page to read. */
5
+ export declare function ReasoningToggle({ open, onToggle, shimmer, children, }: {
6
+ open: boolean;
7
+ onToggle: () => void;
8
+ /** While the model is still thinking the label shimmers. */
9
+ shimmer?: boolean;
10
+ children: ReactNode;
11
+ }): import("react").JSX.Element;
12
+ /** The reasoning itself: smaller and quieter than the answer, hung off a
13
+ * hairline of the accent so it reads as the assistant's aside. */
14
+ export declare function ReasoningBody({ className, children, }: {
15
+ className?: string;
16
+ children: ReactNode;
17
+ }): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { LuChevronRight } from "../../icons/lu/LuChevronRight.js";
2
+ import cx from "classnames";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/ai/components/Reasoning.tsx
5
+ /** The line a turn's reasoning folds under. It opens the reasoning in place,
6
+ * above the answer it led to, rather than in a panel the eye has to cross
7
+ * the page to read. */
8
+ function ReasoningToggle({ open, onToggle, shimmer = false, children }) {
9
+ return /* @__PURE__ */ jsxs("button", {
10
+ type: "button",
11
+ onClick: onToggle,
12
+ "aria-expanded": open,
13
+ className: "group/reasoning -ml-1 flex items-center gap-x-1 rounded-md px-1 py-0.5 text-sm theme-muted-text transition-colors hover:theme-text",
14
+ children: [/* @__PURE__ */ jsx("span", {
15
+ className: cx(shimmer && "animate-shimmer text-shimmer"),
16
+ children
17
+ }), /* @__PURE__ */ jsx(LuChevronRight, { className: cx("h-3.5 w-3.5 transition-transform duration-200 motion-reduce:transition-none", open && "rotate-90") })]
18
+ });
19
+ }
20
+ /** The reasoning itself: smaller and quieter than the answer, hung off a
21
+ * hairline of the accent so it reads as the assistant's aside. */
22
+ function ReasoningBody({ className, children }) {
23
+ return /* @__PURE__ */ jsx("div", {
24
+ className: cx("chat-reasoning-body ml-1 mt-1.5 mb-3 pl-4 text-sm leading-relaxed theme-muted-text", className),
25
+ children
26
+ });
27
+ }
28
+ //#endregion
29
+ export { ReasoningBody, ReasoningToggle };
@@ -10,6 +10,7 @@ import { thinkingHeading } from "../../agent/transcript.js";
10
10
  import { useChatConfig } from "../../core/config.js";
11
11
  import Markdown from "../../ui/Markdown.js";
12
12
  import { chatProseClasses } from "../../ui/prose.js";
13
+ import { ReasoningBody, ReasoningToggle } from "../Reasoning.js";
13
14
  import cx from "classnames";
14
15
  import { useEffect, useMemo, useState } from "react";
15
16
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -520,41 +521,50 @@ function formatThinkingDuration(ms) {
520
521
  if (s < 60) return `${s}s`;
521
522
  return `${Math.floor(s / 60)}m ${s % 60}s`;
522
523
  }
523
- /** The TUI's thought: the body shows while it streams, then folds to its
524
- * label and opens on request. The composer's activity line carries the
525
- * live signal, so nothing here animates. */
524
+ /** The TUI's thought, drawn like a chat reply's: while it streams its label
525
+ * shimmers over the latest lines, then it folds to the label and opens in
526
+ * place on request. */
526
527
  function ReasoningBlock({ part, isStreaming }) {
527
528
  const { strings } = useChatConfig();
528
529
  const t = strings.thinking;
529
530
  const [expanded, setExpanded] = useState(false);
530
531
  const label = thinkingHeading(part.text) || (part.durationMs ? t.thoughtFor(formatThinkingDuration(part.durationMs)) : t.label);
531
532
  const hasBody = part.text.trim() !== "";
532
- const body = hasBody && /* @__PURE__ */ jsx("div", {
533
- className: "ml-1 border-l theme-border pl-3 text-sm theme-muted-text",
534
- "data-testid": "reasoning-body",
535
- children: /* @__PURE__ */ jsx(Markdown, {
536
- isStreaming: isStreaming ?? false,
537
- children: part.text
538
- })
533
+ const markdown = /* @__PURE__ */ jsx(Markdown, {
534
+ isStreaming: isStreaming ?? false,
535
+ children: part.text
539
536
  });
540
- if (isStreaming) return /* @__PURE__ */ jsx("div", {
537
+ if (isStreaming) return /* @__PURE__ */ jsxs("div", {
541
538
  className: "py-1.5",
542
539
  "data-testid": "reasoning-part",
543
- children: body
540
+ children: [/* @__PURE__ */ jsx("div", {
541
+ className: "animate-shimmer text-shimmer text-sm",
542
+ children: label
543
+ }), hasBody && /* @__PURE__ */ jsx(ReasoningBody, {
544
+ className: "chat-reasoning-tail",
545
+ children: /* @__PURE__ */ jsx("div", {
546
+ className: "flex max-h-20 flex-col justify-end overflow-hidden",
547
+ "data-testid": "reasoning-body",
548
+ children: markdown
549
+ })
550
+ })]
551
+ });
552
+ if (!hasBody) return /* @__PURE__ */ jsx("div", {
553
+ className: "py-1.5 text-sm theme-muted-text",
554
+ "data-testid": "reasoning-part",
555
+ children: label
544
556
  });
545
557
  return /* @__PURE__ */ jsxs("div", {
546
558
  className: "py-1.5",
547
559
  "data-testid": "reasoning-part",
548
- children: [/* @__PURE__ */ jsxs("button", {
549
- type: "button",
550
- onClick: () => hasBody && setExpanded((v) => !v),
551
- "aria-expanded": expanded,
552
- className: cx("flex min-h-6 items-center gap-x-1 text-sm theme-muted-text transition-colors", hasBody && "hover:theme-text"),
553
- children: [/* @__PURE__ */ jsx("span", { children: label }), hasBody && /* @__PURE__ */ jsx(LuChevronRight, { className: cx("w-3.5 h-3.5 transition-transform", expanded && "rotate-90") })]
554
- }), expanded && /* @__PURE__ */ jsx("div", {
555
- className: "mt-1.5",
556
- children: body
557
- })]
560
+ children: [/* @__PURE__ */ jsx(ReasoningToggle, {
561
+ open: expanded,
562
+ onToggle: () => setExpanded((v) => !v),
563
+ children: label
564
+ }), expanded && /* @__PURE__ */ jsx(ReasoningBody, { children: /* @__PURE__ */ jsx("div", {
565
+ "data-testid": "reasoning-body",
566
+ children: markdown
567
+ }) })]
558
568
  });
559
569
  }
560
570
  function TextBlock({ part, isStreaming }) {
@@ -441,6 +441,7 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
441
441
  try {
442
442
  const useResponses = state.models.find((m) => m.id === modelString)?.supports_responses === true;
443
443
  let thinkingStart = null;
444
+ let thinkingEnd = null;
444
445
  const markStreamStart = () => {
445
446
  if (thinkingStart === null) thinkingStart = Date.now();
446
447
  };
@@ -460,6 +461,7 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
460
461
  },
461
462
  onReasoning: (reasoning) => {
462
463
  markStreamStart();
464
+ thinkingEnd = Date.now();
463
465
  dispatch({
464
466
  type: "APPEND_STREAMING_REASONING",
465
467
  reasoning,
@@ -485,7 +487,7 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
485
487
  if (bgStream) bgStream.parts = liveParts;
486
488
  }
487
489
  }, controller.signal);
488
- const thinkingDuration = assistantReasoning && thinkingStart !== null ? Date.now() - thinkingStart : null;
490
+ const thinkingDuration = assistantReasoning && thinkingStart !== null ? (thinkingEnd ?? Date.now()) - thinkingStart : null;
489
491
  const assistantMessage = {
490
492
  id: assistantMessageId || crypto.randomUUID(),
491
493
  parentId: userMessageId,
@@ -5,6 +5,23 @@
5
5
  * rather than invented — an end without a start has nothing to render. */
6
6
  function applyPartDelta(parts, delta) {
7
7
  switch (delta.type) {
8
+ case "text":
9
+ case "reasoning": {
10
+ const last = parts.at(-1);
11
+ if (last?.kind === delta.type) return [...parts.slice(0, -1), {
12
+ ...last,
13
+ text: last.text + delta.text
14
+ }];
15
+ return [...parts, {
16
+ kind: delta.type,
17
+ text: delta.text
18
+ }];
19
+ }
20
+ case "retract": {
21
+ let end = parts.length;
22
+ while (end > 0 && isStreamedText(parts[end - 1])) end -= 1;
23
+ return end === parts.length ? parts : parts.slice(0, end);
24
+ }
8
25
  case "tool_start": return [...parts, {
9
26
  kind: "tool_call",
10
27
  id: delta.id,
@@ -70,6 +87,9 @@ function applyPartDelta(parts, delta) {
70
87
  default: return parts;
71
88
  }
72
89
  }
90
+ function isStreamedText(part) {
91
+ return part?.kind === "text" || part?.kind === "reasoning";
92
+ }
73
93
  /** A turn can end (completed or stopped) with tool calls still marked
74
94
  * running — the transport died before their tool_end arrived. A stored
75
95
  * message must not spin forever, so close them out as interrupted. */
@@ -0,0 +1,6 @@
1
+ /** The highlighter's name for the language a paste is written in, or ''
2
+ * when it is not recognisably one. */
3
+ export declare function guessPasteLanguage(text: string): string;
4
+ /** The paste as a fenced code block in its language. The fence outruns any
5
+ * run of backticks inside the paste, so nothing in it can close the fence. */
6
+ export declare function fencePaste(text: string): string;
@@ -0,0 +1,45 @@
1
+ //#region src/ai/core/pasteLanguage.ts
2
+ function meaningfulLines(text) {
3
+ return text.split("\n").filter((l) => l.trim() !== "" && !/^\s*#/.test(l));
4
+ }
5
+ function mostly(lines, test) {
6
+ return lines.length > 0 && lines.filter(test).length / lines.length >= .8;
7
+ }
8
+ function isJson(trimmed) {
9
+ if (!/^[[{]/.test(trimmed)) return false;
10
+ try {
11
+ JSON.parse(trimmed);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+ var YAML_LINE = /^\s*(-\s+)?["']?[\w./-]+["']?\s*:(\s|$)|^\s*-(\s|$)|^---\s*$/;
18
+ var TOML_LINE = /^\s*\[{1,2}[\w."-]+\]{1,2}\s*$|^\s*[\w."-]+\s*=\s*\S/;
19
+ /** The highlighter's name for the language a paste is written in, or ''
20
+ * when it is not recognisably one. */
21
+ function guessPasteLanguage(text) {
22
+ const trimmed = text.trim();
23
+ if (!trimmed) return "";
24
+ if (isJson(trimmed)) return "json";
25
+ const all = trimmed.split("\n");
26
+ if (all.some((l) => /^(diff --git |@@ -\d)/.test(l))) return "diff";
27
+ if (/^<(!doctype html|html)\b/i.test(trimmed)) return "html";
28
+ if (/^<[?!\w]/.test(trimmed) && trimmed.endsWith(">")) return "xml";
29
+ const first = all[0] ?? "";
30
+ if (/^#!.*\b(ba|z)?sh\b/.test(first) || mostly(meaningfulLines(trimmed), (l) => /^\$ /.test(l))) return "bash";
31
+ if (/^(select|insert|update|delete|create|alter|with)\s/i.test(first)) return "sql";
32
+ const lines = meaningfulLines(trimmed);
33
+ if (lines.length >= 2 && mostly(lines, (l) => YAML_LINE.test(l))) return "yaml";
34
+ if (lines.length >= 2 && mostly(lines, (l) => TOML_LINE.test(l))) return "toml";
35
+ return "";
36
+ }
37
+ /** The paste as a fenced code block in its language. The fence outruns any
38
+ * run of backticks inside the paste, so nothing in it can close the fence. */
39
+ function fencePaste(text) {
40
+ const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
41
+ const fence = "`".repeat(longest + 1);
42
+ return `${fence}${guessPasteLanguage(text)}\n${text.replace(/\n+$/, "")}\n${fence}`;
43
+ }
44
+ //#endregion
45
+ export { fencePaste, guessPasteLanguage };
@@ -39,6 +39,6 @@ export { useKeyboardShortcuts } from './core/useKeyboardShortcuts.js';
39
39
  export { type ChatStrings, defaultChatStrings } from './strings.js';
40
40
  export * from './types.js';
41
41
  export { type DropdownOption, default as Dropdown } from './ui/Dropdown.js';
42
- export { NoticeBar, NoticeCard, type NoticeTone } from './ui/Notice.js';
42
+ export { NoticeBar, NoticeCard, type NoticeTone, OutputCard } from './ui/Notice.js';
43
43
  export { RenameDialog, type RowDialogStrings, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, useRowDialogs, } from './ui/sidebar.js';
44
44
  export { formatFileSize, isMac, isOrgModel } from './utils.js';
package/dist/ai/index.js CHANGED
@@ -43,5 +43,5 @@ import ChatMessage_default from "./components/ChatMessage.js";
43
43
  import ChatMessageList from "./components/ChatMessageList.js";
44
44
  import ChatThread from "./components/ChatThread.js";
45
45
  import ComposerFrame from "./components/ComposerFrame.js";
46
- import { NoticeBar, NoticeCard } from "./ui/Notice.js";
47
- export { AGENT_COLOR_NAMES, ActivityLine, AgentMessageParts, AllocationSelector, ApprovalBlock, AttachmentManager, AttachmentUpload, BlockedGroupBanner, BranchNavigator, ChatAdapterError, ChatEmptyState, ChatInput, ChatLayout, ChatMessageList, ChatMessage_default as ChatMessageView, ChatProvider, ChatSidebar, ChatThread, ComposerContext, ComposerControls, ComposerFrame, ComposerSettings, ConnectToolsLink, DEFAULT_PERMISSION_MODE, DragOverlay, Dropdown, FOCUS_SIDEBAR_SEARCH_EVENT, INTERRUPTED_NOTICE, KeyboardShortcutsProvider, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, ModelSelector, NAMED_PERMISSION_MODES, NoticeBar, NoticeCard, PermissionPicker, ProviderIssueBanner, RenameDialog, ShareButton, ShareDialog, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, agentColorHex, agentTypeLabel, applyPartDelta, askQuestionFromArgs, bashExitCode, chatReducer, clampPermissionMode, classifyPseudoUserMessage, clip, compactCount, computeEditDiff, computeWriteRows, defaultChatStrings, durationToAbsHumanDuration, editSummary, finalizeParts, firstNonEmptyLine, formatElapsed, formatFileSize, fullToolArgs, getGreeting, getProviderKeyFromModelId, hiddenTranscriptTool, initialState, interruptionNotice, isEditDiffTool, isGroupableTool, isMac, isNamedPermissionMode, isOrgModel, languageForPath, lineDelta, parseEditArgs, parseSubagentNotice, pasteInlineMaxBytes, permissionModesUpTo, planFromArgs, plural, providerIssueFor, resolveChatConfig, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, slashMatches, subagentDeliveryLine, summarizeToolArgs, taskCallFromArgs, thinkingHeading, tokenizeDiffLines, toolHeaderFor, toolSummary, useChat, useChatConfig, useDragDrop, useKeyboardShortcuts, useRowDialogs, wordEmphasisRanges };
46
+ import { NoticeBar, NoticeCard, OutputCard } from "./ui/Notice.js";
47
+ export { AGENT_COLOR_NAMES, ActivityLine, AgentMessageParts, AllocationSelector, ApprovalBlock, AttachmentManager, AttachmentUpload, BlockedGroupBanner, BranchNavigator, ChatAdapterError, ChatEmptyState, ChatInput, ChatLayout, ChatMessageList, ChatMessage_default as ChatMessageView, ChatProvider, ChatSidebar, ChatThread, ComposerContext, ComposerControls, ComposerFrame, ComposerSettings, ConnectToolsLink, DEFAULT_PERMISSION_MODE, DragOverlay, Dropdown, FOCUS_SIDEBAR_SEARCH_EVENT, INTERRUPTED_NOTICE, KeyboardShortcutsProvider, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, ModelSelector, NAMED_PERMISSION_MODES, NoticeBar, NoticeCard, OutputCard, PermissionPicker, ProviderIssueBanner, RenameDialog, ShareButton, ShareDialog, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, agentColorHex, agentTypeLabel, applyPartDelta, askQuestionFromArgs, bashExitCode, chatReducer, clampPermissionMode, classifyPseudoUserMessage, clip, compactCount, computeEditDiff, computeWriteRows, defaultChatStrings, durationToAbsHumanDuration, editSummary, finalizeParts, firstNonEmptyLine, formatElapsed, formatFileSize, fullToolArgs, getGreeting, getProviderKeyFromModelId, hiddenTranscriptTool, initialState, interruptionNotice, isEditDiffTool, isGroupableTool, isMac, isNamedPermissionMode, isOrgModel, languageForPath, lineDelta, parseEditArgs, parseSubagentNotice, pasteInlineMaxBytes, permissionModesUpTo, planFromArgs, plural, providerIssueFor, resolveChatConfig, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, slashMatches, subagentDeliveryLine, summarizeToolArgs, taskCallFromArgs, thinkingHeading, tokenizeDiffLines, toolHeaderFor, toolSummary, useChat, useChatConfig, useDragDrop, useKeyboardShortcuts, useRowDialogs, wordEmphasisRanges };
@@ -35,6 +35,15 @@ export interface ChatStrings {
35
35
  };
36
36
  queue: {
37
37
  remove: string;
38
+ /** Under a message that goes out once the running reply finishes. */
39
+ waiting: string;
40
+ /** Under a message a steering host hands to the agent between steps. */
41
+ waitingNextStep: string;
42
+ /** Under a message the host has already handed to the running turn. */
43
+ handedOver: string;
44
+ /** The visible label of the remove control; `remove` names it for
45
+ * assistive technology. */
46
+ removeShort: string;
38
47
  };
39
48
  activity: {
40
49
  tokens: (input: string, output: string) => string;
@@ -32,7 +32,13 @@ var defaultChatStrings = {
32
32
  title: "Billing group \"{group}\" is over its allocation",
33
33
  message: "It has reached its allocation limit. Messages to this provider are paused until your administrator increases the allocation."
34
34
  },
35
- queue: { remove: "Remove queued message" },
35
+ queue: {
36
+ remove: "Remove queued message",
37
+ waiting: "Queued · sends when this reply finishes",
38
+ waitingNextStep: "Queued · reaches the agent after its current step",
39
+ handedOver: "Sent to the running turn",
40
+ removeShort: "Remove"
41
+ },
36
42
  providerIssue: {
37
43
  keyRejected: "API key rejected",
38
44
  unreachable: "Endpoint unreachable",
@@ -10,6 +10,14 @@ export declare function NoticeBar({ tone, title, children, action, onAction, tes
10
10
  onAction?: () => void;
11
11
  testId?: string;
12
12
  }): import("react").JSX.Element;
13
+ /** What a command printed, kept verbatim under the command that printed it. */
14
+ export declare function OutputCard({ title, text, onDismiss, dismissLabel, testId, }: {
15
+ title: string;
16
+ text: string;
17
+ onDismiss?: () => void;
18
+ dismissLabel?: string;
19
+ testId?: string;
20
+ }): import("react").JSX.Element;
13
21
  /** An inline notice next to the composer. One with no dismiss states a fact
14
22
  * that is still true; one with a dismiss is a message the reader can put away. */
15
23
  export declare function NoticeCard({ tone, children, onDismiss, dismissLabel, testId, }: {
@@ -56,6 +56,32 @@ function NoticeBar({ tone, title, children, action, onAction, testId }) {
56
56
  ]
57
57
  });
58
58
  }
59
+ /** What a command printed, kept verbatim under the command that printed it. */
60
+ function OutputCard({ title, text, onDismiss, dismissLabel, testId }) {
61
+ return /* @__PURE__ */ jsxs("section", {
62
+ "data-testid": testId,
63
+ "aria-label": title,
64
+ className: "relative my-3 rounded-lg border theme-border theme-muted-panel px-3 py-2",
65
+ children: [
66
+ /* @__PURE__ */ jsx("p", {
67
+ className: cx("font-mono text-[11px] theme-muted-text", onDismiss && "pr-6"),
68
+ children: title
69
+ }),
70
+ /* @__PURE__ */ jsx("pre", {
71
+ className: "mt-1 whitespace-pre-wrap break-words font-mono text-xs theme-text",
72
+ children: text
73
+ }),
74
+ onDismiss && /* @__PURE__ */ jsx("button", {
75
+ type: "button",
76
+ onClick: onDismiss,
77
+ "aria-label": dismissLabel,
78
+ title: dismissLabel,
79
+ className: "absolute top-1.5 right-1.5 rounded p-1 theme-muted-text hover:theme-hover",
80
+ children: /* @__PURE__ */ jsx(IoMdClose, { className: "h-3.5 w-3.5" })
81
+ })
82
+ ]
83
+ });
84
+ }
59
85
  /** An inline notice next to the composer. One with no dismiss states a fact
60
86
  * that is still true; one with a dismiss is a message the reader can put away. */
61
87
  function NoticeCard({ tone = "neutral", children, onDismiss, dismissLabel, testId }) {
@@ -76,4 +102,4 @@ function NoticeCard({ tone = "neutral", children, onDismiss, dismissLabel, testI
76
102
  });
77
103
  }
78
104
  //#endregion
79
- export { NoticeBar, NoticeCard };
105
+ export { NoticeBar, NoticeCard, OutputCard };
@@ -7,7 +7,7 @@ import { useState } from "react";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/ai/ui/sidebar.tsx
9
9
  var SIDEBAR_RAIL_WIDTH_PX = 48;
10
- var HIGHLIGHT_SELECTED = "bg-[color-mix(in_oklab,var(--theme-panel)_8%,transparent)]";
10
+ var HIGHLIGHT_SELECTED = "bg-[color-mix(in_oklab,var(--theme-accent)_12%,transparent)]";
11
11
  var HIGHLIGHT_HOVER = "hover:bg-[color-mix(in_oklab,var(--theme-panel)_5%,transparent)]";
12
12
  /** The column a conversation list lives in: it animates between its width
13
13
  * and a rail, unless a drag is setting the width right now. */
@@ -37,7 +37,7 @@ function SidebarToggle({ collapsed, onToggle, openLabel, closeLabel, className }
37
37
  function SidebarGroupHeading({ children, tone = "default", testId }) {
38
38
  return /* @__PURE__ */ jsx("h4", {
39
39
  "data-testid": testId,
40
- className: cx("mt-4 mb-1 whitespace-nowrap px-2 text-xs font-medium", tone === "attention" ? "text-amber-700 dark:text-amber-300" : "theme-muted-text"),
40
+ className: cx("mt-5 mb-1 whitespace-nowrap px-2 text-xs font-medium", tone === "attention" ? "text-amber-700 dark:text-amber-300" : "theme-muted-text"),
41
41
  children
42
42
  });
43
43
  }
@@ -46,7 +46,7 @@ function SidebarGroupHeading({ children, tone = "default", testId }) {
46
46
  function SidebarRow({ selected, menuItems, openMenu, className, children }) {
47
47
  const { active, openMenu: openRowMenu } = useRowMenuActive(openMenu);
48
48
  return /* @__PURE__ */ jsxs("li", {
49
- className: cx("group relative flex items-center justify-between rounded-lg px-2 py-1 transition-colors duration-150", selected || active ? HIGHLIGHT_SELECTED : HIGHLIGHT_HOVER, className),
49
+ className: cx("group relative flex items-center justify-between rounded-lg px-2 py-[3px] transition-colors duration-150", selected || active ? HIGHLIGHT_SELECTED : HIGHLIGHT_HOVER, className),
50
50
  ...rowContextMenuProps(openRowMenu, menuItems),
51
51
  children: [children, /* @__PURE__ */ jsx(MoreButton, {
52
52
  active,
@@ -81,6 +81,9 @@ function isStorageDirectory(node) {
81
81
  function getErrorMessage(error) {
82
82
  return error instanceof Error ? error.message : String(error);
83
83
  }
84
+ function isWithinRoots(roots) {
85
+ return (path) => roots.some(([, root]) => path.startsWith(root));
86
+ }
84
87
  /** Directory paths from the storage root down to `path`. */
85
88
  function getAncestorDirPaths(path) {
86
89
  const isDir = path.endsWith("/");
@@ -189,6 +192,8 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
189
192
  const [accessDrawerOpen, setAccessDrawerOpen] = useState(false);
190
193
  const [connectionIssuesMessage, updateConnectionIssuesMessage] = useState(/* @__PURE__ */ new Map());
191
194
  const [hasCorsIssue, setHasCorsIssue] = useState(/* @__PURE__ */ new Map());
195
+ const [previewNode, setPreviewNode] = useState(null);
196
+ const [previewOpen, setPreviewOpen] = useState(false);
192
197
  const storageRoots = useMemo(() => {
193
198
  const roots = /* @__PURE__ */ new Map();
194
199
  for (const node of rootNodes) {
@@ -197,32 +202,52 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
197
202
  }
198
203
  return roots;
199
204
  }, [rootNodes, parentToChildrenMap]);
205
+ const storageRootPaths = useMemo(() => new Map(storages.map((storage) => [storage.id, storage.rootPath])), [storages]);
200
206
  const seenStorageRootsRef = useRef(storageRoots);
201
- const forgetStorages = useEffectEvent((removed) => {
202
- const isUnder = (path) => removed.some(([, root]) => path !== root && path.startsWith(root));
203
- const isWithin = (path) => removed.some(([, root]) => path.startsWith(root));
207
+ const seenRootPathsRef = useRef(storageRootPaths);
208
+ const dropStorageListings = (roots) => {
209
+ const isUnder = (path) => roots.some(([, root]) => path !== root && path.startsWith(root));
210
+ const isWithin = isWithinRoots(roots);
204
211
  for (const path of [...fetchesInFlightRef.current.keys()]) if (isWithin(path)) fetchesInFlightRef.current.delete(path);
205
- setListings((prev) => removed.reduce((next, [, root]) => clearListingsUnder(next, root), prev));
212
+ setListings((prev) => roots.reduce((next, [, root]) => clearListingsUnder(next, root), prev));
206
213
  setLoadingPaths((prev) => new Set([...prev].filter((path) => !isWithin(path))));
207
214
  setExpandedPaths((prev) => new Set([...prev].filter((path) => !isUnder(path))));
208
- const withoutRemoved = (prev) => {
215
+ setCheckedItems((prev) => new Set([...prev].filter((path) => !isWithin(path))));
216
+ const withoutDropped = (prev) => {
209
217
  const next = new Map(prev);
210
- for (const [id] of removed) next.delete(id);
218
+ for (const [id] of roots) next.delete(id);
211
219
  return next;
212
220
  };
213
- updateConnectionIssuesMessage(withoutRemoved);
214
- setHasCorsIssue(withoutRemoved);
221
+ updateConnectionIssuesMessage(withoutDropped);
222
+ setHasCorsIssue(withoutDropped);
223
+ };
224
+ const forgetStorages = useEffectEvent((removed) => {
225
+ dropStorageListings(removed);
215
226
  const selectedRoot = removed.find(([, root]) => normalizedSelectedPath.startsWith(root))?.[1];
216
227
  if (selectedRoot) {
217
228
  const parent = getParentPath(selectedRoot);
218
229
  setSelectedPath(treeMap[parent] ? parent : "");
219
230
  }
220
231
  });
232
+ const rerootStorages = useEffectEvent((rerooted) => {
233
+ dropStorageListings(rerooted);
234
+ clearPreviewCache(isWithinRoots(rerooted));
235
+ if (previewNode && rerooted.some(([id]) => id === previewNode.storageId)) {
236
+ setPreviewOpen(false);
237
+ setPreviewNode(null);
238
+ }
239
+ const selectedRoot = rerooted.find(([, root]) => normalizedSelectedPath.startsWith(root))?.[1];
240
+ if (selectedRoot && selectedRoot !== normalizedSelectedPath) setSelectedPath(selectedRoot);
241
+ });
221
242
  useEffect(() => {
243
+ const seenRootPaths = seenRootPathsRef.current;
222
244
  const removed = [...seenStorageRootsRef.current].filter(([id]) => !storageRoots.has(id));
245
+ const rerooted = [...storageRoots].filter(([id]) => seenRootPaths.has(id) && seenRootPaths.get(id) !== storageRootPaths.get(id));
223
246
  seenStorageRootsRef.current = storageRoots;
247
+ seenRootPathsRef.current = storageRootPaths;
224
248
  if (removed.length > 0) forgetStorages(removed);
225
- }, [storageRoots]);
249
+ if (rerooted.length > 0) rerootStorages(rerooted);
250
+ }, [storageRoots, storageRootPaths]);
226
251
  const [uploadModalOpen, setUploadModalOpen] = useState(false);
227
252
  const [createFolderTarget, setCreateFolderTarget] = useState(null);
228
253
  const [pendingUpload, setPendingUpload] = useState(null);
@@ -231,8 +256,6 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
231
256
  const folderInputRef = useRef(null);
232
257
  const [deleteModalOpen, setDeleteModalOpen] = useState(false);
233
258
  const [pendingDelete, setPendingDelete] = useState(null);
234
- const [previewNode, setPreviewNode] = useState(null);
235
- const [previewOpen, setPreviewOpen] = useState(false);
236
259
  const [detailTab, setDetailTab] = useState("details");
237
260
  useEffect(() => clearPreviewCache, []);
238
261
  const { openMenu, contextMenu } = useRowMenu();
@@ -22,5 +22,5 @@ export declare function loadPreviewContent<T>(path: string, loader: (signal: Abo
22
22
  export declare function fetchPreviewObject(path: string, url: string, signal: AbortSignal, headers?: HeadersInit): Promise<Response>;
23
23
  export declare function peekPreviewUrl(path: string): UrlResult | undefined;
24
24
  export declare function peekPreviewContent<T>(path: string): T | undefined;
25
- export declare function clearPreviewCache(): void;
25
+ export declare function clearPreviewCache(isCleared?: (path: string) => boolean): void;
26
26
  export {};
@@ -89,10 +89,12 @@ function peekPreviewUrl(path) {
89
89
  function peekPreviewContent(path) {
90
90
  return contentCache.get(path)?.value;
91
91
  }
92
- function clearPreviewCache() {
93
- for (const record of contentCache.values()) disposeContent(record);
94
- contentCache.clear();
95
- urlCache.clear();
92
+ function clearPreviewCache(isCleared = () => true) {
93
+ for (const [path, record] of contentCache) if (isCleared(path)) {
94
+ disposeContent(record);
95
+ contentCache.delete(path);
96
+ }
97
+ for (const path of urlCache.keys()) if (isCleared(path)) urlCache.delete(path);
96
98
  }
97
99
  //#endregion
98
100
  export { clearPreviewCache, fetchPreviewObject, loadPreviewContent, loadPreviewUrl, peekPreviewContent, peekPreviewUrl };
@@ -26,6 +26,7 @@ export type TStorage = {
26
26
  canShare?: boolean | undefined;
27
27
  canManageAccess?: boolean | undefined;
28
28
  previewLimits?: Partial<PreviewLimits> | undefined;
29
+ rootPath?: string | undefined;
29
30
  };
30
31
  /** A file the host is asked to name or download: its storage, key and name. */
31
32
  export interface ExplorerObject {