@djangocfg/widget-chat 0.1.29 → 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.
|
|
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.
|
|
70
|
-
"@djangocfg/i18n": "^2.1.
|
|
71
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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.
|
|
79
|
-
"@djangocfg/i18n": "^2.1.
|
|
80
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
81
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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
|
package/src/styles/composer.css
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/ui/chat-composer.tsx
CHANGED
|
@@ -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=
|
|
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
|
+
}
|
package/src/ui/panel-header.tsx
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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}
|