@djangocfg/widget-chat 0.1.28 → 0.1.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/widget-chat",
3
- "version": "0.1.28",
3
+ "version": "0.1.31",
4
4
  "description": "A transport-agnostic chat surface: a pure core, a store, primitives, a composer, and the assembled components a host mounts",
5
5
  "keywords": [
6
6
  "chat",
@@ -66,19 +66,19 @@
66
66
  "use-stick-to-bottom": "^1.1.6"
67
67
  },
68
68
  "peerDependencies": {
69
- "@djangocfg/api": "^2.1.627",
70
- "@djangocfg/i18n": "^2.1.627",
71
- "@djangocfg/ui-core": "^2.1.627",
69
+ "@djangocfg/api": "^2.1.635",
70
+ "@djangocfg/i18n": "^2.1.635",
71
+ "@djangocfg/ui-core": "^2.1.635",
72
72
  "lucide-react": "^1.68.0",
73
73
  "react": "^19.0.0",
74
74
  "remark-gfm": "^4.0.0",
75
75
  "streamdown": "^2.6.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@djangocfg/api": "^2.1.627",
79
- "@djangocfg/i18n": "^2.1.627",
80
- "@djangocfg/typescript-config": "^2.1.627",
81
- "@djangocfg/ui-core": "^2.1.627",
78
+ "@djangocfg/api": "^2.1.635",
79
+ "@djangocfg/i18n": "^2.1.635",
80
+ "@djangocfg/typescript-config": "^2.1.635",
81
+ "@djangocfg/ui-core": "^2.1.635",
82
82
  "@storybook/react-vite": "^10.6.5",
83
83
  "@types/node": "^25.9.5",
84
84
  "@types/react": "19.3.0",
@@ -48,7 +48,7 @@ const BASE =
48
48
  // The design system's control radius, matching the box around them: both are
49
49
  // rounded rectangles from the same scale, so the row reads as one object.
50
50
  // (It was `rounded-full` while the box was a capsule — the box drives this.)
51
- "shrink-0 items-center justify-center rounded-[var(--radius-control,0.625rem)] transition-colors " +
51
+ "shrink-0 items-center justify-center rounded-[var(--chat-composer-button-radius,var(--radius-control,0.625rem))] transition-colors " +
52
52
  // Height DERIVED from the field beside it, never a literal — a hardcoded 32px
53
53
  // silently went 2px short the moment the body text moved from 15px to 16. The
54
54
  // expression lives in `composer.css`, which needs the same number to keep the
@@ -28,7 +28,8 @@
28
28
  * with `width: 100%` the rule matched, loaded, and changed nothing.
29
29
  */
30
30
  .composer-box:has(textarea[data-multiline]) > *:has(> textarea),
31
- .composer-box:has(.tool-chips > *) > *:has(> textarea) {
31
+ .composer-box:has(.tool-chips > *) > *:has(> textarea),
32
+ .composer-box.composer-stacked > *:has(> textarea) {
32
33
  flex-basis: 100%;
33
34
  }
34
35
 
@@ -42,10 +43,19 @@
42
43
  * beneath, `+` still at their left edge.
43
44
  */
44
45
  .composer-box:has(textarea[data-multiline]) > .composer-lead,
45
- .composer-box:has(.tool-chips > *) > .composer-lead {
46
+ .composer-box:has(.tool-chips > *) > .composer-lead,
47
+ .composer-box.composer-stacked > .composer-lead {
46
48
  order: 3;
47
49
  }
48
50
 
51
+ /**
52
+ * The stacked composer (Notion's): the field on top with room for two lines, the controls always on their own row beneath it.
53
+ * Opt-in by the `stacked` prop, so the single-row composer of other hosts is unchanged.
54
+ */
55
+ .composer-box.composer-stacked textarea {
56
+ min-height: calc(var(--chat-text, 1rem) * var(--chat-leading, 1.5) * 2);
57
+ }
58
+
49
59
  /* The `+` sits `inset-y` from the left edge, matching the send button's gap to
50
60
  * the right and the bottom. The box's horizontal inset is breathing room for
51
61
  * the TEXT and is wider, so the difference is given back here — the same
@@ -245,18 +255,19 @@
245
255
  /* CONCENTRIC with the controls: their radius plus the inset that clears them.
246
256
  * Derived rather than a second token, so a control radius change moves both
247
257
  * arcs together and the corners cannot stop running parallel. */
248
- border-radius: calc(var(--radius-control, 0.625rem) + var(--chat-composer-inset-y));
258
+ border-radius: var(--chat-composer-radius, calc(var(--radius-control, 0.625rem) + var(--chat-composer-inset-y)));
249
259
  /* The horizontal inset is the TEXT's breathing room and is deliberately wider
250
260
  * than the vertical one. The control row gives that difference back on both
251
261
  * ends (`.composer-controls`), so a corner button keeps an equal gap to the
252
262
  * bottom and the side while the field still starts clear of the edge. */
253
263
  padding: var(--chat-composer-inset-y) var(--chat-composer-inset-x);
254
- box-shadow: 0 0 0 2px color-mix(in oklab, var(--border) 55%, transparent);
264
+ background-color: var(--chat-composer-bg, transparent);
265
+ box-shadow: 0 0 0 var(--chat-composer-ring-w, 2px) var(--chat-composer-ring, color-mix(in oklab, var(--border) 55%, transparent));
255
266
  transition: box-shadow 120ms ease;
256
267
  }
257
268
 
258
269
  .composer-box:focus-within {
259
- box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 40%, transparent);
270
+ box-shadow: 0 0 0 var(--chat-composer-ring-w, 2px) var(--chat-composer-ring-focus, color-mix(in oklab, var(--ring) 40%, transparent));
260
271
  }
261
272
 
262
273
 
@@ -82,6 +82,8 @@ export interface ChatComposerProps {
82
82
  * stale SILENTLY — an unmapped key still renders.
83
83
  */
84
84
  readonly toolCategoryLabel?: (category: string) => string;
85
+ /** Field on top, controls always on their own row beneath (the Notion arrangement). Absent: the controls share the field's row while the draft is short. */
86
+ readonly stacked?: boolean;
85
87
  readonly className?: string;
86
88
  }
87
89
 
@@ -122,6 +124,7 @@ export function ChatComposer({
122
124
  suggestionIcon,
123
125
  toolIcon,
124
126
  toolCategoryLabel,
127
+ stacked = false,
125
128
  className,
126
129
  }: ChatComposerProps) {
127
130
  return (
@@ -141,7 +144,7 @@ export function ChatComposer({
141
144
  transcript's scrollbar can reach the pane. Here rather than there because
142
145
  only this element knows it is the composer's outer box.
143
146
  */
144
- <div className="chat-column chat-inset-r relative">
147
+ <div className={`chat-column chat-inset-r relative${stacked ? " pt-3" : ""}`}>
145
148
  {above}
146
149
  {/*
147
150
  Part of the assembly, not a host slot — the same reason `ComposerQueue`
@@ -162,7 +165,7 @@ export function ChatComposer({
162
165
  descendant and acts on a different child, legible as CSS and unreadable
163
166
  as a class list.
164
167
  */
165
- className={`composer-box flex cursor-text flex-wrap items-end gap-2 border border-border bg-background focus-within:border-ring ${className ?? ""}`}
168
+ className={`composer-box ${stacked ? "composer-stacked " : ""}flex cursor-text flex-wrap items-end gap-2 border border-border bg-background focus-within:border-ring ${className ?? ""}`}
166
169
  >
167
170
  <DraftPersistence room={draftRoom} />
168
171
  <ChatReplyChip />
@@ -32,7 +32,7 @@ export function CollapsibleRow({
32
32
  const collapse = useCollapse(
33
33
  message.status.state === "streaming",
34
34
  parts,
35
- useMayCollapse(message.id),
35
+ useMayCollapse(message.id) && !carriesWidget(message),
36
36
  );
37
37
  return (
38
38
  <ChatMessageFrame
@@ -79,3 +79,11 @@ function useMayCollapse(id: string): boolean {
79
79
  const ids = useMessageIds();
80
80
  return ids[ids.length - 1] !== id;
81
81
  }
82
+
83
+ /**
84
+ * A turn that carries a widget (an approval, a media row, a table) is never folded: the card is the point of the turn, it can be waiting for a
85
+ * click, and the fold's fixed height cut it in half.
86
+ */
87
+ function carriesWidget(message: ChatMessage): boolean {
88
+ return message.parts.some((part) => part.type === "data");
89
+ }
package/src/ui/index.ts CHANGED
@@ -23,6 +23,7 @@ export { ChatMessageView, ChatMessageFrame, MessageProse } from "./message";
23
23
  export { CollapsibleBody } from "./collapsible-body";
24
24
  export { JumpToLatest } from "./jump-to-latest";
25
25
  export type { JumpToLatestProps } from "./jump-to-latest";
26
+ export { MessageTime } from "./message-time";
26
27
  export { MessageActions, messageCopyText, useCopyAction } from "./message-actions";
27
28
  export type {
28
29
  MessageAction,
@@ -0,0 +1,32 @@
1
+ import { cn } from "@djangocfg/ui-core/lib";
2
+ import { formatTimeOfDay } from "@djangocfg/widget-kit";
3
+
4
+ import type { ChatMessage } from "../core/parts";
5
+
6
+ /**
7
+ * The time a message was sent, quiet beside its actions and shown with them (on hover or focus within the row).
8
+ *
9
+ * The instant is stored as UTC (an ISO string on the wire, epoch milliseconds in the message) and drawn in the READER'S zone and
10
+ * locale by `formatTimeOfDay` — no zone name is ever shown. The exact instant stays one hover away in the native `title` as UTC
11
+ * ISO, which is also the `dateTime` a machine reads. A message with no usable time draws nothing.
12
+ */
13
+ export function MessageTime({ message, className }: { readonly message: ChatMessage; readonly className?: string }) {
14
+ const at = message.createdAt;
15
+ if (!at) return null;
16
+ const label = formatTimeOfDay(at);
17
+ if (label === "") return null;
18
+ const iso = new Date(at).toISOString();
19
+ return (
20
+ <time
21
+ dateTime={iso}
22
+ title={iso}
23
+ className={cn(
24
+ "text-xs text-muted-foreground tabular-nums opacity-0 transition-opacity",
25
+ "group-hover:opacity-100 group-focus-within:opacity-100",
26
+ className,
27
+ )}
28
+ >
29
+ {label}
30
+ </time>
31
+ );
32
+ }
@@ -5,7 +5,7 @@ import { Button } from "@djangocfg/ui-core/components";
5
5
  import { cn } from "@djangocfg/ui-core/lib";
6
6
 
7
7
  /** The heading's look, one for every surface (column, side panel, phone sheet). The caller supplies the element: `h2`, or the dialog's own title. */
8
- export const PANEL_TITLE_CLASS = "truncate font-display text-sm font-semibold uppercase tracking-wide text-muted-foreground outline-none";
8
+ export const PANEL_TITLE_CLASS = "truncate font-display text-sm font-(weight:--panel-title-weight) tracking-(--panel-title-tracking) text-(color:--panel-title-color) [text-transform:var(--panel-title-transform)] outline-none";
9
9
 
10
10
  export interface ChatPanelHeaderProps {
11
11
  /** The heading element, styled with `PANEL_TITLE_CLASS`. */
@@ -25,7 +25,8 @@ export interface ChatPanelHeaderProps {
25
25
  */
26
26
  export function ChatPanelHeader({ heading, description, actions, close, className }: ChatPanelHeaderProps) {
27
27
  return (
28
- <header className={cn("flex shrink-0 items-center justify-between gap-3 border-b px-4 py-2.5", className)}>
28
+ // A fixed height, `--panel-header-h` (3rem): a host gives its own page header the same value, so the two hairlines meet on one horizontal line.
29
+ <header className={cn("box-border flex h-[var(--panel-header-h,3rem)] shrink-0 items-center justify-between gap-3 border-b px-4", className)}>
29
30
  <div className="min-w-0">
30
31
  {heading}
31
32
  {description ? <div className="truncate text-xs text-muted-foreground">{description}</div> : null}