@windowkit/agent-chat 0.1.0 → 0.4.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 (118) hide show
  1. package/dist/core/driver.d.ts +13 -0
  2. package/dist/core/driver.d.ts.map +1 -1
  3. package/dist/core/driver.js.map +1 -1
  4. package/dist/core/markdown.js +29 -0
  5. package/dist/core/markdown.js.map +1 -1
  6. package/dist/core/reducer.d.ts.map +1 -1
  7. package/dist/core/reducer.js +16 -0
  8. package/dist/core/reducer.js.map +1 -1
  9. package/dist/core/session.d.ts +3 -0
  10. package/dist/core/session.d.ts.map +1 -1
  11. package/dist/core/session.js +17 -1
  12. package/dist/core/session.js.map +1 -1
  13. package/dist/core/types.d.ts +43 -1
  14. package/dist/core/types.d.ts.map +1 -1
  15. package/dist/core/types.js.map +1 -1
  16. package/dist/drivers/claude/index.d.ts.map +1 -1
  17. package/dist/drivers/claude/index.js +56 -8
  18. package/dist/drivers/claude/index.js.map +1 -1
  19. package/dist/drivers/claude/results.d.ts +40 -0
  20. package/dist/drivers/claude/results.d.ts.map +1 -0
  21. package/dist/drivers/claude/results.js +235 -0
  22. package/dist/drivers/claude/results.js.map +1 -0
  23. package/dist/drivers/claude/tools.d.ts +3 -1
  24. package/dist/drivers/claude/tools.d.ts.map +1 -1
  25. package/dist/drivers/claude/tools.js +7 -1
  26. package/dist/drivers/claude/tools.js.map +1 -1
  27. package/dist/view/AgentChat.d.ts.map +1 -1
  28. package/dist/view/AgentChat.js +14 -3
  29. package/dist/view/AgentChat.js.map +1 -1
  30. package/dist/view/ArtifactCard.d.ts +6 -0
  31. package/dist/view/ArtifactCard.d.ts.map +1 -0
  32. package/dist/view/ArtifactCard.js +6 -0
  33. package/dist/view/ArtifactCard.js.map +1 -0
  34. package/dist/view/AttachmentCard.d.ts +8 -0
  35. package/dist/view/AttachmentCard.d.ts.map +1 -0
  36. package/dist/view/AttachmentCard.js +55 -0
  37. package/dist/view/AttachmentCard.js.map +1 -0
  38. package/dist/view/CodeBlock.d.ts.map +1 -1
  39. package/dist/view/CodeBlock.js +18 -11
  40. package/dist/view/CodeBlock.js.map +1 -1
  41. package/dist/view/Composer.d.ts.map +1 -1
  42. package/dist/view/Composer.js +9 -1
  43. package/dist/view/Composer.js.map +1 -1
  44. package/dist/view/DataPartView.d.ts +6 -0
  45. package/dist/view/DataPartView.d.ts.map +1 -0
  46. package/dist/view/DataPartView.js +23 -0
  47. package/dist/view/DataPartView.js.map +1 -0
  48. package/dist/view/ImageView.d.ts +18 -0
  49. package/dist/view/ImageView.d.ts.map +1 -0
  50. package/dist/view/ImageView.js +35 -0
  51. package/dist/view/ImageView.js.map +1 -0
  52. package/dist/view/LinkCard.d.ts +14 -0
  53. package/dist/view/LinkCard.d.ts.map +1 -0
  54. package/dist/view/LinkCard.js +40 -0
  55. package/dist/view/LinkCard.js.map +1 -0
  56. package/dist/view/MessageList.d.ts.map +1 -1
  57. package/dist/view/MessageList.js +10 -10
  58. package/dist/view/MessageList.js.map +1 -1
  59. package/dist/view/PlanChecklist.d.ts +6 -0
  60. package/dist/view/PlanChecklist.d.ts.map +1 -0
  61. package/dist/view/PlanChecklist.js +16 -0
  62. package/dist/view/PlanChecklist.js.map +1 -0
  63. package/dist/view/PullRequestCard.d.ts +6 -0
  64. package/dist/view/PullRequestCard.d.ts.map +1 -0
  65. package/dist/view/PullRequestCard.js +12 -0
  66. package/dist/view/PullRequestCard.js.map +1 -0
  67. package/dist/view/SessionCard.d.ts +6 -0
  68. package/dist/view/SessionCard.d.ts.map +1 -0
  69. package/dist/view/SessionCard.js +6 -0
  70. package/dist/view/SessionCard.js.map +1 -0
  71. package/dist/view/SuggestedReplies.d.ts +5 -0
  72. package/dist/view/SuggestedReplies.d.ts.map +1 -0
  73. package/dist/view/SuggestedReplies.js +10 -0
  74. package/dist/view/SuggestedReplies.js.map +1 -0
  75. package/dist/view/TextBlock.d.ts.map +1 -1
  76. package/dist/view/TextBlock.js +4 -1
  77. package/dist/view/TextBlock.js.map +1 -1
  78. package/dist/view/ToolCard.d.ts.map +1 -1
  79. package/dist/view/ToolCard.js +4 -1
  80. package/dist/view/ToolCard.js.map +1 -1
  81. package/dist/view/Transcript.d.ts.map +1 -1
  82. package/dist/view/Transcript.js +18 -2
  83. package/dist/view/Transcript.js.map +1 -1
  84. package/dist/view/context.d.ts +9 -0
  85. package/dist/view/context.d.ts.map +1 -1
  86. package/dist/view/context.js +9 -0
  87. package/dist/view/context.js.map +1 -1
  88. package/dist/view/look.d.ts +21 -0
  89. package/dist/view/look.d.ts.map +1 -1
  90. package/dist/view/look.js +17 -0
  91. package/dist/view/look.js.map +1 -1
  92. package/package.json +3 -3
  93. package/src/core/driver.ts +4 -0
  94. package/src/core/markdown.ts +34 -0
  95. package/src/core/reducer.ts +25 -0
  96. package/src/core/session.ts +17 -1
  97. package/src/core/types.ts +51 -1
  98. package/src/drivers/claude/index.ts +62 -7
  99. package/src/drivers/claude/results.ts +252 -0
  100. package/src/drivers/claude/tools.ts +7 -1
  101. package/src/view/AgentChat.tsx +20 -2
  102. package/src/view/ArtifactCard.tsx +18 -0
  103. package/src/view/AttachmentCard.tsx +103 -0
  104. package/src/view/CodeBlock.tsx +18 -11
  105. package/src/view/Composer.tsx +10 -1
  106. package/src/view/DataPartView.tsx +40 -0
  107. package/src/view/ImageView.tsx +64 -0
  108. package/src/view/LinkCard.tsx +99 -0
  109. package/src/view/MessageList.tsx +21 -39
  110. package/src/view/PlanChecklist.tsx +49 -0
  111. package/src/view/PullRequestCard.tsx +28 -0
  112. package/src/view/SessionCard.tsx +22 -0
  113. package/src/view/SuggestedReplies.tsx +34 -0
  114. package/src/view/TextBlock.tsx +4 -1
  115. package/src/view/ToolCard.tsx +10 -1
  116. package/src/view/Transcript.tsx +20 -1
  117. package/src/view/context.ts +19 -0
  118. package/src/view/look.ts +38 -0
@@ -1,6 +1,7 @@
1
1
  // A fenced code block with a header of actions: Copy by default, and
2
2
  // whatever the app's `codeActions` add. Nothing here runs code; an action
3
- // that does is the app's.
3
+ // that does is the app's. It is drawn as `<Markdown>` draws a fence, with
4
+ // the header inside the block's fill.
4
5
 
5
6
  import { useEffect, useRef, useState, type ReactElement } from 'react';
6
7
  import { Button, useClipboard } from 'react-x11';
@@ -9,7 +10,7 @@ import type { FenceInfo } from '@react-x11/components/markdown';
9
10
 
10
11
  import type { AgentMessage } from '../core/types.js';
11
12
  import { useChat } from './context.js';
12
- import { useLook } from './look.js';
13
+ import { useCodeLook, useLook } from './look.js';
13
14
  import type { CodeAction, CodeActionContext } from './types.js';
14
15
 
15
16
  export interface CodeBlockProps {
@@ -18,8 +19,9 @@ export interface CodeBlockProps {
18
19
  }
19
20
 
20
21
  export function CodeBlock({ fence, message }: CodeBlockProps): ReactElement {
21
- const { codeActions, session } = useChat();
22
+ const { codeActions, session, fontSize } = useChat();
22
23
  const look = useLook();
24
+ const code = useCodeLook(fontSize);
23
25
  const clipboard = useClipboard();
24
26
  const [done, setDone] = useState<string | null>(null);
25
27
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -62,9 +64,8 @@ export function CodeBlock({ fence, message }: CodeBlockProps): ReactElement {
62
64
  <box
63
65
  style={{
64
66
  flexDirection: 'column',
65
- borderWidth: 1,
66
- borderColor: look.border,
67
- borderRadius: 6,
67
+ backgroundColor: code.fill,
68
+ borderRadius: code.radius,
68
69
  overflow: 'hidden',
69
70
  }}
70
71
  >
@@ -73,11 +74,10 @@ export function CodeBlock({ fence, message }: CodeBlockProps): ReactElement {
73
74
  flexDirection: 'row',
74
75
  alignItems: 'center',
75
76
  gap: 4,
76
- paddingLeft: 10,
77
- paddingRight: 4,
77
+ paddingLeft: code.padding,
78
+ paddingRight: Math.round(code.padding / 3),
79
+ paddingTop: Math.round(code.padding / 3),
78
80
  minHeight: 26,
79
- borderBottomWidth: 1,
80
- borderBottomColor: look.border,
81
81
  }}
82
82
  >
83
83
  <text style={{ color: look.muted, fontSize: 11, flexGrow: 1 }}>
@@ -100,8 +100,15 @@ export function CodeBlock({ fence, message }: CodeBlockProps): ReactElement {
100
100
  <Code
101
101
  source={fence.text}
102
102
  {...(fence.lang ? { lang: fence.lang } : {})}
103
+ fontSize={code.size}
103
104
  wrap
104
- style={{ borderWidth: 0, borderRadius: 0 }}
105
+ // the frame holds the fill: the code's own, a tint, would lay a
106
+ // second over it
107
+ style={{
108
+ backgroundColor: 'transparent',
109
+ borderRadius: 0,
110
+ paddingTop: Math.round(code.padding / 3),
111
+ }}
105
112
  />
106
113
  </box>
107
114
  );
@@ -59,11 +59,20 @@ export function Composer({
59
59
  autoFocus,
60
60
  options = {},
61
61
  }: ComposerProps): ReactElement {
62
- const { session } = useChat();
62
+ const { session, composer } = useChat();
63
63
  const snap = useSessionSnapshot(session);
64
64
  const look = useLook();
65
65
  const editor = useRef<RichTextEditorHandle | null>(null);
66
66
 
67
+ // a suggested reply, put in the box to edit or send
68
+ useEffect(() => {
69
+ composer.attach((text) => {
70
+ editor.current?.setValue(text);
71
+ editor.current?.focus();
72
+ });
73
+ return () => composer.attach(null);
74
+ }, [composer]);
75
+
67
76
  const busy =
68
77
  snap.status === 'running' ||
69
78
  snap.status === 'waiting' ||
@@ -0,0 +1,40 @@
1
+ // A data part, drawn by its name. The names are the ones this package's
2
+ // drivers write (`DataValues`); a part named anything else, or with a value
3
+ // that is not the shape its name says, draws nothing.
4
+
5
+ import type { ReactElement } from 'react';
6
+
7
+ import type { DataPart, DataValues } from '../core/types.js';
8
+ import { ArtifactCard } from './ArtifactCard.js';
9
+ import { AttachmentCard } from './AttachmentCard.js';
10
+ import { PullRequestCard } from './PullRequestCard.js';
11
+ import { SessionCard } from './SessionCard.js';
12
+
13
+ export function DataPartView({
14
+ part,
15
+ }: {
16
+ part: DataPart;
17
+ }): ReactElement | null {
18
+ const v = part.value as Record<string, unknown> | null | undefined;
19
+ if (!v || typeof v !== 'object') return null;
20
+ switch (part.name) {
21
+ case 'pull-request':
22
+ return typeof v.number === 'number' && typeof v.action === 'string' ? (
23
+ <PullRequestCard value={v as unknown as DataValues['pull-request']} />
24
+ ) : null;
25
+ case 'session':
26
+ return typeof v.url === 'string' ? (
27
+ <SessionCard value={v as unknown as DataValues['session']} />
28
+ ) : null;
29
+ case 'artifact':
30
+ return typeof v.url === 'string' ? (
31
+ <ArtifactCard value={v as unknown as DataValues['artifact']} />
32
+ ) : null;
33
+ case 'attachments':
34
+ return Array.isArray(v.files) ? (
35
+ <AttachmentCard value={v as unknown as DataValues['attachments']} />
36
+ ) : null;
37
+ default:
38
+ return null;
39
+ }
40
+ }
@@ -0,0 +1,64 @@
1
+ // An image the agent read, a tool returned, or the user pasted: base64
2
+ // bytes, or a file on this machine. No taller than the look says, never
3
+ // wider than the space on offer, and in its own proportions.
4
+ //
5
+ // A format this runtime cannot decode (GIF under Node) draws nothing, as a
6
+ // missing file does; `alt` is still what a screen reader says.
7
+
8
+ import path from 'node:path';
9
+ import { useMemo, type ReactElement } from 'react';
10
+
11
+ import { useLook } from './look.js';
12
+
13
+ export interface ImageViewProps {
14
+ /** base64 bytes, or a path, relative to `cwd` unless absolute. */
15
+ source: { data: string } | { path: string; cwd?: string };
16
+ /** Pixels, where known: drawn at that size, scaled down to fit. */
17
+ width?: number;
18
+ height?: number;
19
+ /** `look.imageHeight` unless said. */
20
+ maxHeight?: number;
21
+ alt: string;
22
+ }
23
+
24
+ export function ImageView({
25
+ source,
26
+ width,
27
+ height,
28
+ maxHeight,
29
+ alt,
30
+ }: ImageViewProps): ReactElement {
31
+ const look = useLook();
32
+ const limit = maxHeight ?? look.imageHeight;
33
+ const data = 'data' in source ? source.data : undefined;
34
+ const file =
35
+ 'path' in source
36
+ ? source.cwd
37
+ ? path.resolve(source.cwd, source.path)
38
+ : source.path
39
+ : undefined;
40
+ const src = useMemo(
41
+ () => (data !== undefined ? Buffer.from(data, 'base64') : file!),
42
+ [data, file],
43
+ );
44
+ const size =
45
+ width && height
46
+ ? (() => {
47
+ const scale = Math.min(1, limit / height);
48
+ return { width: width * scale, height: height * scale };
49
+ })()
50
+ : { maxHeight: limit };
51
+ return (
52
+ <image
53
+ src={src}
54
+ alt={alt}
55
+ style={{
56
+ ...size,
57
+ maxWidth: '100%',
58
+ objectFit: 'contain',
59
+ alignSelf: 'flex-start',
60
+ borderRadius: look.radius,
61
+ }}
62
+ />
63
+ );
64
+ }
@@ -0,0 +1,99 @@
1
+ // The frame every link card shares: what the link is, its title, where it
2
+ // goes, and an Open button that hands the address to the app's `onLink`.
3
+ // Without `onLink` there is no button — nothing here navigates by itself —
4
+ // and the address is still there to read.
5
+
6
+ import type { ReactElement } from 'react';
7
+ import { Button } from 'react-x11';
8
+
9
+ import { useChat } from './context.js';
10
+ import { useLook } from './look.js';
11
+
12
+ export interface LinkCardProps {
13
+ /** What kind of link: `Pull request`, `Session`, `Artifact`. */
14
+ kind: string;
15
+ title: string;
16
+ /** A line under the title: what happened, where it is. */
17
+ detail?: string;
18
+ /** Where Open goes. Absent: no button. */
19
+ href?: string;
20
+ }
21
+
22
+ export function LinkCard({
23
+ kind,
24
+ title,
25
+ detail,
26
+ href,
27
+ }: LinkCardProps): ReactElement {
28
+ const { onLink } = useChat();
29
+ const look = useLook();
30
+ return (
31
+ <box
32
+ style={{
33
+ flexDirection: 'row',
34
+ alignItems: 'center',
35
+ gap: look.inset,
36
+ padding: look.inset,
37
+ borderWidth: 1,
38
+ borderColor: look.border,
39
+ borderRadius: look.radius,
40
+ }}
41
+ aria-label={`${kind}: ${title}`}
42
+ >
43
+ {/* the accent edge: a stripe, since a border of one side is square */}
44
+ <box
45
+ style={{
46
+ width: 3,
47
+ alignSelf: 'stretch',
48
+ borderRadius: 2,
49
+ backgroundColor: look.accent,
50
+ }}
51
+ />
52
+ <box
53
+ style={{
54
+ flexDirection: 'column',
55
+ gap: 2,
56
+ flexGrow: 1,
57
+ flexShrink: 1,
58
+ }}
59
+ >
60
+ <text
61
+ style={{
62
+ color: look.muted,
63
+ fontSize: look.smallSize,
64
+ fontWeight: 'bold',
65
+ }}
66
+ >
67
+ {kind.toUpperCase()}
68
+ </text>
69
+ <text style={{ color: look.text, fontWeight: 'bold', maxLines: 2 }}>
70
+ {title}
71
+ </text>
72
+ {detail ? (
73
+ <text
74
+ style={{
75
+ color: look.muted,
76
+ fontSize: look.captionSize,
77
+ maxLines: 1,
78
+ }}
79
+ >
80
+ {detail}
81
+ </text>
82
+ ) : null}
83
+ </box>
84
+ {href && onLink ? (
85
+ <Button
86
+ size="small"
87
+ variant="outline"
88
+ label="Open"
89
+ onPress={() => onLink(href)}
90
+ />
91
+ ) : null}
92
+ </box>
93
+ );
94
+ }
95
+
96
+ /** `github.com/o/r/pull/7` for `https://github.com/o/r/pull/7`. */
97
+ export function shortUrl(href: string): string {
98
+ return href.replace(/^[a-z]+:\/\//i, '').replace(/\/$/, '');
99
+ }
@@ -6,8 +6,11 @@ import { memo, useState, type ReactElement } from 'react';
6
6
  import { Button, Icon, useClipboard } from 'react-x11';
7
7
 
8
8
  import { messageMarkdown, sourceLabel } from '../core/markdown.js';
9
- import type { AgentMessage, Part, PlanPart } from '../core/types.js';
9
+ import type { AgentMessage, FilePart, Part } from '../core/types.js';
10
+ import { DataPartView } from './DataPartView.js';
11
+ import { ImageView } from './ImageView.js';
10
12
  import { useLook, type ChatLook } from './look.js';
13
+ import { PlanChecklist } from './PlanChecklist.js';
11
14
  import { QuestionCard } from './QuestionCard.js';
12
15
  import { TextBlock } from './TextBlock.js';
13
16
  import { ToolCard } from './ToolCard.js';
@@ -89,6 +92,14 @@ function UserMessage({
89
92
  streaming={false}
90
93
  message={message}
91
94
  />
95
+ ) : p.type === 'file' && isImage(p) ? (
96
+ // a pasted screenshot is the picture, not its token
97
+ <ImageView
98
+ key={p.id}
99
+ source={{ data: p.data! }}
100
+ maxHeight={look.thumbHeight}
101
+ alt={p.label}
102
+ />
92
103
  ) : p.type === 'file' && !textOf(message).includes(`[${p.label}]`) ? (
93
104
  // an attachment the composer made is already in the text as its
94
105
  // token; one sent some other way gets a line of its own
@@ -157,7 +168,7 @@ function PartView({
157
168
  case 'question':
158
169
  return <QuestionCard part={part} />;
159
170
  case 'plan':
160
- return <Plan part={part} look={look} />;
171
+ return <PlanChecklist part={part} />;
161
172
  case 'notice':
162
173
  return (
163
174
  <text
@@ -175,13 +186,15 @@ function PartView({
175
186
  </text>
176
187
  );
177
188
  case 'file':
178
- return (
189
+ return isImage(part) ? (
190
+ <ImageView source={{ data: part.data! }} alt={part.label} />
191
+ ) : (
179
192
  <text
180
193
  style={{ color: look.muted, fontSize: 11 }}
181
194
  >{`[${part.label}]`}</text>
182
195
  );
183
196
  case 'data':
184
- return null;
197
+ return <DataPartView part={part} />;
185
198
  }
186
199
  }
187
200
 
@@ -232,41 +245,6 @@ function Reasoning({
232
245
  );
233
246
  }
234
247
 
235
- function Plan({
236
- part,
237
- look,
238
- }: {
239
- part: PlanPart;
240
- look: ChatLook;
241
- }): ReactElement {
242
- return (
243
- <box style={{ flexDirection: 'column', gap: 2 }}>
244
- {part.entries.map((e, i) => (
245
- <box
246
- key={i}
247
- style={{ flexDirection: 'row', gap: 6, alignItems: 'center' }}
248
- >
249
- {e.status === 'completed' ? (
250
- <Icon name="check" size={12} color={look.success} />
251
- ) : e.status === 'in_progress' ? (
252
- <Icon name="dot" size={12} color={look.accent} />
253
- ) : (
254
- <Icon name="dash" size={12} color={look.muted} />
255
- )}
256
- <text
257
- style={{
258
- color: e.status === 'completed' ? look.muted : look.text,
259
- fontWeight: e.status === 'in_progress' ? 'bold' : 'normal',
260
- }}
261
- >
262
- {e.text}
263
- </text>
264
- </box>
265
- ))}
266
- </box>
267
- );
268
- }
269
-
270
248
  function CopyButton({ message }: { message: AgentMessage }): ReactElement {
271
249
  const clipboard = useClipboard();
272
250
  const [copied, setCopied] = useState(false);
@@ -285,6 +263,10 @@ function CopyButton({ message }: { message: AgentMessage }): ReactElement {
285
263
  );
286
264
  }
287
265
 
266
+ function isImage(p: FilePart): boolean {
267
+ return p.mime.startsWith('image/') && !!p.data;
268
+ }
269
+
288
270
  function textOf(message: AgentMessage): string {
289
271
  return message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
290
272
  }
@@ -0,0 +1,49 @@
1
+ // The agent's plan as a checklist: Claude Code's task list (or a
2
+ // `TodoWrite`), kept current as it works — what is done in the muted
3
+ // colour, what is under way in bold, and how far along it is.
4
+
5
+ import type { ReactElement } from 'react';
6
+ import { Icon } from 'react-x11';
7
+
8
+ import type { PlanPart } from '../core/types.js';
9
+ import { useLook } from './look.js';
10
+
11
+ export function PlanChecklist({ part }: { part: PlanPart }): ReactElement {
12
+ const look = useLook();
13
+ const done = part.entries.filter((e) => e.status === 'completed').length;
14
+ return (
15
+ <box style={{ flexDirection: 'column', gap: 2 }} aria-label="Plan">
16
+ <text
17
+ style={{
18
+ color: look.muted,
19
+ fontSize: look.smallSize,
20
+ fontWeight: 'bold',
21
+ }}
22
+ >
23
+ {`PLAN · ${done} OF ${part.entries.length} DONE`}
24
+ </text>
25
+ {part.entries.map((e, i) => (
26
+ <box
27
+ key={i}
28
+ style={{ flexDirection: 'row', gap: look.gap, alignItems: 'center' }}
29
+ >
30
+ {e.status === 'completed' ? (
31
+ <Icon name="check" size={look.captionSize} color={look.success} />
32
+ ) : e.status === 'in_progress' ? (
33
+ <Icon name="dot" size={look.captionSize} color={look.accent} />
34
+ ) : (
35
+ <Icon name="dash" size={look.captionSize} color={look.muted} />
36
+ )}
37
+ <text
38
+ style={{
39
+ color: e.status === 'completed' ? look.muted : look.text,
40
+ fontWeight: e.status === 'in_progress' ? 'bold' : 'normal',
41
+ }}
42
+ >
43
+ {e.text}
44
+ </text>
45
+ </box>
46
+ ))}
47
+ </box>
48
+ );
49
+ }
@@ -0,0 +1,28 @@
1
+ // A pull request a command opened, edited or merged: Bash's
2
+ // `gitOperation.pr`.
3
+
4
+ import type { ReactElement } from 'react';
5
+
6
+ import type { PullRequestData } from '../core/types.js';
7
+ import { LinkCard, shortUrl } from './LinkCard.js';
8
+
9
+ export function PullRequestCard({
10
+ value,
11
+ }: {
12
+ value: PullRequestData;
13
+ }): ReactElement {
14
+ const action = value.action.replace(/-/g, ' ');
15
+ return (
16
+ <LinkCard
17
+ kind="Pull request"
18
+ title={`#${value.number}`}
19
+ detail={[
20
+ action.charAt(0).toUpperCase() + action.slice(1),
21
+ value.url ? shortUrl(value.url) : '',
22
+ ]
23
+ .filter(Boolean)
24
+ .join(' · ')}
25
+ {...(value.url ? { href: value.url } : {})}
26
+ />
27
+ );
28
+ }
@@ -0,0 +1,22 @@
1
+ // An agent session started somewhere else — a cloud session the Agent tool
2
+ // launched, a workflow — which the user can open and follow.
3
+
4
+ import type { ReactElement } from 'react';
5
+
6
+ import type { SessionLinkData } from '../core/types.js';
7
+ import { LinkCard, shortUrl } from './LinkCard.js';
8
+
9
+ export function SessionCard({
10
+ value,
11
+ }: {
12
+ value: SessionLinkData;
13
+ }): ReactElement {
14
+ return (
15
+ <LinkCard
16
+ kind="Session started"
17
+ title={value.title ?? 'A new session'}
18
+ detail={shortUrl(value.url)}
19
+ href={value.url}
20
+ />
21
+ );
22
+ }
@@ -0,0 +1,34 @@
1
+ // What the harness suggests the user might say next, as chips under the
2
+ // last reply. Pressing one puts it in the composer to edit or send; it never
3
+ // sends by itself.
4
+
5
+ import type { ReactElement } from 'react';
6
+ import { Button } from 'react-x11';
7
+
8
+ import { useChat } from './context.js';
9
+ import { useLook } from './look.js';
10
+
11
+ export function SuggestedReplies({
12
+ suggestions,
13
+ }: {
14
+ suggestions: readonly string[];
15
+ }): ReactElement {
16
+ const { composer } = useChat();
17
+ const look = useLook();
18
+ return (
19
+ <box
20
+ style={{ flexDirection: 'row', flexWrap: 'wrap', gap: look.gap }}
21
+ aria-label="Suggested replies"
22
+ >
23
+ {suggestions.map((s) => (
24
+ <Button
25
+ key={s}
26
+ size="small"
27
+ variant="outline"
28
+ label={s}
29
+ onPress={() => composer.fill(s)}
30
+ />
31
+ ))}
32
+ </box>
33
+ );
34
+ }
@@ -7,6 +7,7 @@ import { Markdown } from '@react-x11/components/markdown';
7
7
  import type { AgentMessage } from '../core/types.js';
8
8
  import { CodeBlock } from './CodeBlock.js';
9
9
  import { useChat } from './context.js';
10
+ import { useCodeLook } from './look.js';
10
11
  import { splitFences } from './fences.js';
11
12
 
12
13
  export interface TextBlockProps {
@@ -21,6 +22,8 @@ export const TextBlock = memo(function TextBlock({
21
22
  message,
22
23
  }: TextBlockProps): ReactElement {
23
24
  const { fences, components, onLink, fontSize } = useChat();
25
+ // segments alternate prose and fences: the gap is the space a fence keeps
26
+ const { spacing } = useCodeLook(fontSize);
24
27
  const segments = useMemo(
25
28
  () => splitFences(text, streaming),
26
29
  [text, streaming],
@@ -30,7 +33,7 @@ export const TextBlock = memo(function TextBlock({
30
33
  [onLink],
31
34
  );
32
35
  return (
33
- <box style={{ flexDirection: 'column', gap: 8 }}>
36
+ <box style={{ flexDirection: 'column', gap: spacing }}>
34
37
  {segments.map((s, i) => {
35
38
  if (s.type === 'prose') {
36
39
  return (
@@ -12,6 +12,7 @@ import { TerminalOutput } from '@react-x11/components/terminal-output';
12
12
 
13
13
  import type { ToolOutput, ToolPart } from '../core/types.js';
14
14
  import { useChat } from './context.js';
15
+ import { ImageView } from './ImageView.js';
15
16
  import { clipLines, useLook, type ChatLook } from './look.js';
16
17
  import { MessageList } from './MessageList.js';
17
18
 
@@ -235,7 +236,15 @@ function Output({
235
236
  case 'diff':
236
237
  return <Diff output={output} />;
237
238
  case 'image':
238
- return <text style={{ color: look.muted }}>[image, {output.mime}]</text>;
239
+ return (
240
+ <ImageView
241
+ source={{ data: output.data }}
242
+ {...(output.width && output.height
243
+ ? { width: output.width, height: output.height }
244
+ : {})}
245
+ alt={`An image, ${output.mime}`}
246
+ />
247
+ );
239
248
  case 'value':
240
249
  return (
241
250
  <Code source={clipLines(json(output.value), 80)} lang="json" wrap />
@@ -1,5 +1,8 @@
1
1
  // The scrolling transcript. It follows the newest message while the reader
2
- // is at the bottom, and stops following once they scroll up to read.
2
+ // is at the bottom, and stops following once they scroll up to read. It
3
+ // follows content that grows after it was laid out too: a fence the app
4
+ // draws itself (a diagram compiled after mount) would otherwise push the end
5
+ // of the reply out of view.
3
6
 
4
7
  import {
5
8
  useLayoutEffect,
@@ -12,6 +15,7 @@ import type { ScrollableNode, ScrollEvent } from 'react-x11';
12
15
  import { useChat } from './context.js';
13
16
  import { useLook } from './look.js';
14
17
  import { MessageList } from './MessageList.js';
18
+ import { SuggestedReplies } from './SuggestedReplies.js';
15
19
  import { useSessionSnapshot } from './use-agent.js';
16
20
 
17
21
  /** Within this many pixels of the end counts as at the end. */
@@ -30,16 +34,30 @@ export function Transcript({ empty }: { empty?: ReactNode }): ReactElement {
30
34
  if (stick.current) pane.current?.scrollTo(Infinity);
31
35
  }, [snap.messages, snap.status]);
32
36
 
37
+ // fires from layout whenever the content or viewport size changes, not
38
+ // only when the snapshot does
39
+ const onViewport = () => {
40
+ if (stick.current) pane.current?.scrollTo(Infinity);
41
+ };
42
+
33
43
  const onScroll = (ev: ScrollEvent) => {
34
44
  stick.current =
35
45
  ev.scrollY + ev.viewportHeight >= ev.contentHeight - STICK_PX;
36
46
  };
37
47
 
38
48
  const activity = activityOf(snap.status, snap.messages);
49
+ // offered once the turn is over, under the reply they follow
50
+ const suggestions =
51
+ snap.status === 'idle' &&
52
+ snap.suggestions.length &&
53
+ snap.messages[snap.messages.length - 1]?.role === 'assistant'
54
+ ? snap.suggestions
55
+ : null;
39
56
  return (
40
57
  <box
41
58
  ref={pane}
42
59
  onScroll={onScroll}
60
+ onViewport={onViewport}
43
61
  style={{ flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: 'scroll' }}
44
62
  data-testname="agent-chat-transcript"
45
63
  >
@@ -49,6 +67,7 @@ export function Transcript({ empty }: { empty?: ReactNode }): ReactElement {
49
67
  ) : (
50
68
  (empty ?? null)
51
69
  )}
70
+ {suggestions ? <SuggestedReplies suggestions={suggestions} /> : null}
52
71
  {activity ? (
53
72
  <text style={{ color: look.muted, fontSize: 12 }}>{activity}</text>
54
73
  ) : null}
@@ -33,6 +33,25 @@ export interface ChatContextValue {
33
33
  onLink: ((href: string) => void) | undefined;
34
34
  /** Base text size, the theme's unless the app said. */
35
35
  fontSize: number | undefined;
36
+ /** The message box, for a part that puts text in it without sending. */
37
+ composer: ComposerChannel;
38
+ }
39
+
40
+ /** How a part reaches the composer: it attaches while mounted. */
41
+ export interface ComposerChannel {
42
+ /** Replace what is in the box, and focus it. Sends nothing. */
43
+ fill(text: string): void;
44
+ attach(fill: ((text: string) => void) | null): void;
45
+ }
46
+
47
+ export function composerChannel(): ComposerChannel {
48
+ let current: ((text: string) => void) | null = null;
49
+ return {
50
+ fill: (text) => current?.(text),
51
+ attach: (fill) => {
52
+ current = fill;
53
+ },
54
+ };
36
55
  }
37
56
 
38
57
  export const ChatContext = createContext<ChatContextValue | null>(null);