@classic-homes/agent-chat 0.11.0 → 0.13.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 (117) hide show
  1. package/README.md +67 -2
  2. package/dist/components/AgentChat.svelte +60 -25
  3. package/dist/components/AgentChat.svelte.d.ts +11 -1
  4. package/dist/components/AgentChat.svelte.d.ts.map +1 -1
  5. package/dist/components/ArtifactPanel.svelte +4 -2
  6. package/dist/components/AssistantMessage.svelte +58 -16
  7. package/dist/components/AssistantMessage.svelte.d.ts +2 -0
  8. package/dist/components/AssistantMessage.svelte.d.ts.map +1 -1
  9. package/dist/components/ChatHeader.svelte +4 -1
  10. package/dist/components/ChatHeader.svelte.d.ts +2 -0
  11. package/dist/components/ChatHeader.svelte.d.ts.map +1 -1
  12. package/dist/components/ChatLauncher.svelte +175 -0
  13. package/dist/components/ChatLauncher.svelte.d.ts +26 -0
  14. package/dist/components/ChatLauncher.svelte.d.ts.map +1 -0
  15. package/dist/components/Composer.svelte +7 -3
  16. package/dist/components/Composer.svelte.d.ts.map +1 -1
  17. package/dist/components/FloatingWindow.svelte +280 -0
  18. package/dist/components/FloatingWindow.svelte.d.ts +35 -0
  19. package/dist/components/FloatingWindow.svelte.d.ts.map +1 -0
  20. package/dist/components/Markdown.svelte +4 -0
  21. package/dist/components/Markdown.svelte.d.ts.map +1 -1
  22. package/dist/components/MessageFeedback.svelte +11 -4
  23. package/dist/components/MessageFeedback.svelte.d.ts.map +1 -1
  24. package/dist/components/MessageFiles.svelte +133 -40
  25. package/dist/components/MessageFiles.svelte.d.ts +6 -0
  26. package/dist/components/MessageFiles.svelte.d.ts.map +1 -1
  27. package/dist/components/MessageItem.svelte +2 -0
  28. package/dist/components/MessageItem.svelte.d.ts +2 -0
  29. package/dist/components/MessageItem.svelte.d.ts.map +1 -1
  30. package/dist/components/MessageList.svelte +40 -7
  31. package/dist/components/MessageList.svelte.d.ts +3 -1
  32. package/dist/components/MessageList.svelte.d.ts.map +1 -1
  33. package/dist/components/ShortcutsDialog.svelte +10 -3
  34. package/dist/components/ShortcutsDialog.svelte.d.ts +2 -0
  35. package/dist/components/ShortcutsDialog.svelte.d.ts.map +1 -1
  36. package/dist/components/UiFrame.svelte +12 -4
  37. package/dist/components/UiFrame.svelte.d.ts.map +1 -1
  38. package/dist/components/UserMessage.svelte +10 -1
  39. package/dist/components/UserMessage.svelte.d.ts.map +1 -1
  40. package/dist/index.d.ts +5 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +5 -0
  43. package/dist/markdown.d.ts.map +1 -1
  44. package/dist/markdown.js +94 -61
  45. package/dist/reducer.d.ts +11 -0
  46. package/dist/reducer.d.ts.map +1 -1
  47. package/dist/reducer.js +18 -2
  48. package/dist/session.svelte.d.ts +40 -0
  49. package/dist/session.svelte.d.ts.map +1 -1
  50. package/dist/session.svelte.js +178 -23
  51. package/dist/shortcuts.d.ts +7 -0
  52. package/dist/shortcuts.d.ts.map +1 -1
  53. package/dist/shortcuts.js +9 -0
  54. package/dist/surface-geometry.d.ts +60 -0
  55. package/dist/surface-geometry.d.ts.map +1 -0
  56. package/dist/surface-geometry.js +112 -0
  57. package/dist/surface.svelte.d.ts +79 -0
  58. package/dist/surface.svelte.d.ts.map +1 -0
  59. package/dist/surface.svelte.js +178 -0
  60. package/dist/thinking.d.ts +10 -0
  61. package/dist/thinking.d.ts.map +1 -0
  62. package/dist/thinking.js +61 -0
  63. package/dist/ui-frame-protocol.d.ts +6 -0
  64. package/dist/ui-frame-protocol.d.ts.map +1 -1
  65. package/dist/ui-frame-protocol.js +11 -0
  66. package/package.json +8 -7
  67. package/dist/artifact-pages.test.d.ts +0 -2
  68. package/dist/artifact-pages.test.d.ts.map +0 -1
  69. package/dist/artifact-pages.test.js +0 -102
  70. package/dist/components/a11y.svelte.test.d.ts +0 -2
  71. package/dist/components/a11y.svelte.test.d.ts.map +0 -1
  72. package/dist/components/a11y.svelte.test.js +0 -183
  73. package/dist/components/agent-picker.svelte.test.d.ts +0 -2
  74. package/dist/components/agent-picker.svelte.test.d.ts.map +0 -1
  75. package/dist/components/agent-picker.svelte.test.js +0 -84
  76. package/dist/components/composer.svelte.test.d.ts +0 -2
  77. package/dist/components/composer.svelte.test.d.ts.map +0 -1
  78. package/dist/components/composer.svelte.test.js +0 -162
  79. package/dist/components/continue.svelte.test.d.ts +0 -2
  80. package/dist/components/continue.svelte.test.d.ts.map +0 -1
  81. package/dist/components/continue.svelte.test.js +0 -109
  82. package/dist/components/conversation-chrome.svelte.test.d.ts +0 -2
  83. package/dist/components/conversation-chrome.svelte.test.d.ts.map +0 -1
  84. package/dist/components/conversation-chrome.svelte.test.js +0 -147
  85. package/dist/components/feedback.svelte.test.d.ts +0 -2
  86. package/dist/components/feedback.svelte.test.d.ts.map +0 -1
  87. package/dist/components/feedback.svelte.test.js +0 -99
  88. package/dist/components/live-fixes.svelte.test.d.ts +0 -2
  89. package/dist/components/live-fixes.svelte.test.d.ts.map +0 -1
  90. package/dist/components/live-fixes.svelte.test.js +0 -127
  91. package/dist/components/markdown-prose.svelte.test.d.ts +0 -2
  92. package/dist/components/markdown-prose.svelte.test.d.ts.map +0 -1
  93. package/dist/components/markdown-prose.svelte.test.js +0 -26
  94. package/dist/components/messages.svelte.test.d.ts +0 -2
  95. package/dist/components/messages.svelte.test.d.ts.map +0 -1
  96. package/dist/components/messages.svelte.test.js +0 -215
  97. package/dist/components/regenerate.svelte.test.d.ts +0 -2
  98. package/dist/components/regenerate.svelte.test.d.ts.map +0 -1
  99. package/dist/components/regenerate.svelte.test.js +0 -108
  100. package/dist/components/welcome.svelte.test.d.ts +0 -2
  101. package/dist/components/welcome.svelte.test.d.ts.map +0 -1
  102. package/dist/components/welcome.svelte.test.js +0 -119
  103. package/dist/composer-keys.test.d.ts +0 -2
  104. package/dist/composer-keys.test.d.ts.map +0 -1
  105. package/dist/composer-keys.test.js +0 -16
  106. package/dist/markdown.test.d.ts +0 -2
  107. package/dist/markdown.test.d.ts.map +0 -1
  108. package/dist/markdown.test.js +0 -35
  109. package/dist/reducer.test.d.ts +0 -2
  110. package/dist/reducer.test.d.ts.map +0 -1
  111. package/dist/reducer.test.js +0 -153
  112. package/dist/ui-frame-protocol.test.d.ts +0 -2
  113. package/dist/ui-frame-protocol.test.d.ts.map +0 -1
  114. package/dist/ui-frame-protocol.test.js +0 -48
  115. package/dist/ui-frame-relay.test.d.ts +0 -2
  116. package/dist/ui-frame-relay.test.d.ts.map +0 -1
  117. package/dist/ui-frame-relay.test.js +0 -36
@@ -0,0 +1,175 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The floating launcher: a round button that opens the chat. It starts in a viewport corner;
4
+ * the user can drag it anywhere and it stays where it is dropped (kept inside the viewport),
5
+ * independent of where the chat window sits. It also reports what the chat
6
+ * is doing while collapsed (answering, an unseen answer, an error) and keeps the mock backend
7
+ * visible (AGENTS.md: mock must always be visible).
8
+ */
9
+ import { cn, Icon } from '@classic-homes/theme-svelte';
10
+ import type { LauncherStatus } from '../surface.svelte.js';
11
+ import { DRAG_THRESHOLD, clampLauncher, type Corner, type Point } from '../surface-geometry.js';
12
+
13
+ interface Props {
14
+ /** Where it sits until the user places it. */
15
+ corner?: Corner;
16
+ /** Where the user placed it (top-left, viewport px); null or absent uses `corner`. */
17
+ position?: Point | null;
18
+ status?: LauncherStatus;
19
+ /** The backend is the mock: show the marker on the button. */
20
+ mock?: boolean;
21
+ label?: string;
22
+ /** Id of the surface this button opens, for `aria-controls`. */
23
+ controls?: string;
24
+ expanded?: boolean;
25
+ draggable?: boolean;
26
+ onclick?: () => void;
27
+ /** The user dropped the button here (top-left, already inside the viewport). */
28
+ onmove?: (position: Point) => void;
29
+ class?: string;
30
+ }
31
+ let {
32
+ corner = 'bottom-right',
33
+ position = null,
34
+ status = 'idle',
35
+ mock = false,
36
+ label = 'Classic Agent',
37
+ controls,
38
+ expanded = false,
39
+ draggable = true,
40
+ onclick,
41
+ onmove,
42
+ class: className,
43
+ }: Props = $props();
44
+
45
+ let button: HTMLButtonElement | undefined = $state();
46
+ export function focus(): void {
47
+ button?.focus();
48
+ }
49
+
50
+ const STATUS_TEXT: Record<LauncherStatus, string> = {
51
+ idle: '',
52
+ streaming: 'answering',
53
+ unread: 'new answer',
54
+ error: 'something went wrong',
55
+ };
56
+ const name = $derived(
57
+ [label, STATUS_TEXT[status], mock ? 'mock backend' : ''].filter(Boolean).join(', '),
58
+ );
59
+
60
+ const CORNER_CLASS: Record<Corner, string> = {
61
+ 'bottom-right': 'bottom-5 right-5',
62
+ 'bottom-left': 'bottom-5 left-5',
63
+ 'top-right': 'top-5 right-5',
64
+ 'top-left': 'top-5 left-5',
65
+ };
66
+
67
+ const viewport = () => ({ width: window.innerWidth, height: window.innerHeight });
68
+ // A placed button has to stay reachable when the window shrinks.
69
+ let size = $state(typeof window === 'undefined' ? { width: 1280, height: 800 } : viewport());
70
+ const placed = $derived(position ? clampLauncher(position, size) : null);
71
+
72
+ let start: Point | null = null;
73
+ let offset = $state<Point>({ x: 0, y: 0 });
74
+ let dragging = $state(false);
75
+ // A drag ends in a click event on the same button; that click must not open the chat.
76
+ let suppressClick = false;
77
+
78
+ function onpointerdown(event: PointerEvent): void {
79
+ if (!draggable || event.button !== 0) return;
80
+ start = { x: event.clientX, y: event.clientY };
81
+ button?.setPointerCapture?.(event.pointerId);
82
+ }
83
+ function onpointermove(event: PointerEvent): void {
84
+ if (!start) return;
85
+ const dx = event.clientX - start.x;
86
+ const dy = event.clientY - start.y;
87
+ if (!dragging && Math.abs(dx) <= DRAG_THRESHOLD && Math.abs(dy) <= DRAG_THRESHOLD) return;
88
+ dragging = true;
89
+ offset = { x: dx, y: dy };
90
+ }
91
+ function onpointerup(event: PointerEvent): void {
92
+ if (!start) return;
93
+ start = null;
94
+ button?.releasePointerCapture?.(event.pointerId);
95
+ if (!dragging) return;
96
+ dragging = false;
97
+ suppressClick = true;
98
+ // Where the button is now, drag offset included.
99
+ const box = button?.getBoundingClientRect();
100
+ offset = { x: 0, y: 0 };
101
+ if (box) onmove?.(clampLauncher({ x: box.left, y: box.top }, viewport()));
102
+ }
103
+ function onpointercancel(): void {
104
+ start = null;
105
+ dragging = false;
106
+ offset = { x: 0, y: 0 };
107
+ }
108
+ function handleClick(): void {
109
+ if (suppressClick) {
110
+ suppressClick = false;
111
+ return;
112
+ }
113
+ onclick?.();
114
+ }
115
+ </script>
116
+
117
+ <svelte:window onresize={() => (size = viewport())} />
118
+
119
+ <button
120
+ bind:this={button}
121
+ type="button"
122
+ class={cn(
123
+ 'fixed z-40 flex size-14 touch-none select-none items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg outline-none ring-offset-background transition-shadow hover:shadow-xl focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
124
+ !placed && CORNER_CLASS[corner],
125
+ dragging ? 'scale-105 cursor-grabbing opacity-85' : 'cursor-pointer',
126
+ className,
127
+ )}
128
+ style:left={placed ? `${placed.x}px` : undefined}
129
+ style:top={placed ? `${placed.y}px` : undefined}
130
+ style:transform={dragging ? `translate(${offset.x}px, ${offset.y}px)` : undefined}
131
+ aria-label={name}
132
+ aria-expanded={expanded}
133
+ aria-controls={controls}
134
+ data-agent-launcher
135
+ data-corner={corner}
136
+ data-status={status}
137
+ onclick={handleClick}
138
+ {onpointerdown}
139
+ {onpointermove}
140
+ {onpointerup}
141
+ {onpointercancel}
142
+ >
143
+ {#if status === 'streaming'}
144
+ <!-- Answering: an arc orbits just outside the button and the icon breathes. With reduced motion
145
+ the rim stays as a still ring. -->
146
+ <span
147
+ class="pointer-events-none absolute -inset-[7px] rounded-full animate-spin [animation-duration:1.1s] motion-reduce:animate-none"
148
+ style="background: conic-gradient(from 0deg, transparent 0 35%, hsl(var(--primary)) 100%); -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px)); mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px));"
149
+ aria-hidden="true"
150
+ data-testid="launcher-ring"
151
+ ></span>
152
+ {/if}
153
+ <span class={cn('flex', status === 'streaming' && 'motion-safe:animate-pulse')}>
154
+ <Icon name="sparkles" size={24} />
155
+ </span>
156
+ {#if status === 'unread' || status === 'error'}
157
+ <span
158
+ class={cn(
159
+ 'pointer-events-none absolute right-0.5 top-0.5 size-3.5 rounded-full border-2 border-background',
160
+ status === 'error' ? 'bg-destructive' : 'bg-success',
161
+ )}
162
+ aria-hidden="true"
163
+ data-testid="launcher-dot"
164
+ ></span>
165
+ {/if}
166
+ {#if mock}
167
+ <span
168
+ class="pointer-events-none absolute -bottom-1 left-1/2 -translate-x-1/2 rounded bg-warning px-1 text-[10px] font-bold leading-4 tracking-wide text-warning-foreground"
169
+ aria-hidden="true"
170
+ data-testid="launcher-mock"
171
+ >
172
+ MOCK
173
+ </span>
174
+ {/if}
175
+ </button>
@@ -0,0 +1,26 @@
1
+ import type { LauncherStatus } from '../surface.svelte.js';
2
+ import { type Corner, type Point } from '../surface-geometry.js';
3
+ interface Props {
4
+ /** Where it sits until the user places it. */
5
+ corner?: Corner;
6
+ /** Where the user placed it (top-left, viewport px); null or absent uses `corner`. */
7
+ position?: Point | null;
8
+ status?: LauncherStatus;
9
+ /** The backend is the mock: show the marker on the button. */
10
+ mock?: boolean;
11
+ label?: string;
12
+ /** Id of the surface this button opens, for `aria-controls`. */
13
+ controls?: string;
14
+ expanded?: boolean;
15
+ draggable?: boolean;
16
+ onclick?: () => void;
17
+ /** The user dropped the button here (top-left, already inside the viewport). */
18
+ onmove?: (position: Point) => void;
19
+ class?: string;
20
+ }
21
+ declare const ChatLauncher: import("svelte").Component<Props, {
22
+ focus: () => void;
23
+ }, "">;
24
+ type ChatLauncher = ReturnType<typeof ChatLauncher>;
25
+ export default ChatLauncher;
26
+ //# sourceMappingURL=ChatLauncher.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ChatLauncher.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ChatLauncher.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAiC,KAAK,MAAM,EAAE,KAAK,KAAK,EAAE,MAAM,wBAAwB,CAAC;AAG9F,UAAU,KAAK;IACb,8CAA8C;IAC9C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC;IACxB,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,gFAAgF;IAChF,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,KAAK,KAAK,IAAI,CAAC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA2HH,QAAA,MAAM,YAAY;iBArGG,IAAI;MAqGiC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -150,6 +150,8 @@
150
150
  function onpaste(event: ClipboardEvent): void {
151
151
  const pasted = [...(event.clipboardData?.files ?? [])];
152
152
  if (!upload || pasted.length === 0) return;
153
+ // Excel and Word put both text and a picture of the cells on the clipboard: paste the text.
154
+ if (event.clipboardData?.getData?.('text/plain')) return;
153
155
  // Pasting a screenshot or a copied file attaches it; plain text pastes as usual.
154
156
  event.preventDefault();
155
157
  void addFiles(pasted);
@@ -281,9 +283,11 @@
281
283
  class={cn(
282
284
  'text-xs tabular-nums text-muted-foreground',
283
285
  chars >= LIMITS.messageChars && 'text-destructive',
284
- )}
285
- aria-live="polite"
286
- >{chars.toLocaleString()} / {LIMITS.messageChars.toLocaleString()}</span
286
+ )}>{chars.toLocaleString()} / {LIMITS.messageChars.toLocaleString()}</span
287
+ >
288
+ <!-- Announced once when the limit is crossed, not on every keystroke. -->
289
+ <span class="sr-only" aria-live="polite"
290
+ >{chars > LIMITS.messageChars ? 'Message is over the length limit' : ''}</span
287
291
  >
288
292
  {/if}
289
293
  {#if streaming}
@@ -1 +1 @@
1
- {"version":3,"file":"Composer.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/Composer.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAG9D,OAAO,EAA8B,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAIvF,UAAU,KAAK;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1C,oEAAoE;IACpE,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;CAClC;AA6PH;;;;GAIG;AACH,QAAA,MAAM,QAAQ;iBAhOO,IAAI;qBAgO6B,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Composer.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/Composer.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAG9D,OAAO,EAA8B,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAIvF,UAAU,KAAK;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1C,oEAAoE;IACpE,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;CAClC;AAiQH;;;;GAIG;AACH,QAAA,MAAM,QAAQ;iBApOO,IAAI;qBAoO6B,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,280 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The detached chat window: a non-modal floating panel the user can drag by its header and
4
+ * resize from any edge, so the page under it stays usable. Geometry lives in the `ChatSurface`
5
+ * it is given. Keyboard: the move handle takes arrow keys (Shift resizes, Alt for small
6
+ * steps); Escape inside the window minimizes it unless something inside used the key.
7
+ */
8
+ import { onMount, untrack, type Snippet } from 'svelte';
9
+ import { Button, cn, Icon } from '@classic-homes/theme-svelte';
10
+ import type { ChatSurface } from '../surface.svelte.js';
11
+ import {
12
+ DRAG_THRESHOLD,
13
+ KEY_STEP,
14
+ RESIZE_DIRECTIONS,
15
+ isDockDrop,
16
+ type Point,
17
+ type ResizeDirection,
18
+ } from '../surface-geometry.js';
19
+
20
+ interface Props {
21
+ surface: ChatSurface;
22
+ title?: string;
23
+ /** A second, quieter line under the title, e.g. the conversation's name. */
24
+ subtitle?: string;
25
+ /** Replaces the mark before the title. */
26
+ icon?: Snippet;
27
+ id?: string;
28
+ /** Header buttons before Dock and Minimize, e.g. New chat. */
29
+ actions?: Snippet;
30
+ children: Snippet;
31
+ /** Offer docking (button, and dropping the window on the right edge). */
32
+ dockable?: boolean;
33
+ resizable?: boolean;
34
+ /** Called after the window minimizes, e.g. to move focus to the launcher. */
35
+ onminimize?: () => void;
36
+ /**
37
+ * Take the keyboard when the window appears and nothing inside has it yet: the first text
38
+ * field, or the window itself. Without it Escape and Tab start from the page underneath.
39
+ */
40
+ autofocus?: boolean;
41
+ class?: string;
42
+ }
43
+ let {
44
+ surface,
45
+ title = 'Classic Agent',
46
+ subtitle,
47
+ icon,
48
+ id = 'agent-chat-window',
49
+ actions,
50
+ children,
51
+ dockable = true,
52
+ resizable = true,
53
+ onminimize,
54
+ autofocus = true,
55
+ class: className,
56
+ }: Props = $props();
57
+
58
+ const rect = $derived(surface.rect);
59
+ // Untracked: attaching clamps the saved rect, which must not re-run this effect.
60
+ $effect(() => untrack(() => surface.attach()));
61
+
62
+ let root: HTMLElement | undefined = $state();
63
+ // After the content has mounted (and had its own chance to focus something).
64
+ onMount(() => {
65
+ if (!autofocus || !root || root.contains(document.activeElement)) return;
66
+ const field = root.querySelector<HTMLElement>(
67
+ 'textarea:not([disabled]), input:not([disabled])',
68
+ );
69
+ (field ?? root).focus({ preventScroll: true });
70
+ });
71
+
72
+ function minimize(): void {
73
+ surface.minimize();
74
+ onminimize?.();
75
+ }
76
+
77
+ // --- drag by the header -------------------------------------------------------------------
78
+ let last: Point | null = null;
79
+ let dragged = false;
80
+ let dockHint = $state(false);
81
+ const viewport = () => ({ width: window.innerWidth, height: window.innerHeight });
82
+
83
+ function capture(event: PointerEvent): void {
84
+ (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
85
+ }
86
+ function release(event: PointerEvent): void {
87
+ (event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
88
+ }
89
+
90
+ function ondragdown(event: PointerEvent): void {
91
+ if (event.button !== 0) return;
92
+ // Buttons in the header keep their clicks.
93
+ if ((event.target as HTMLElement).closest('button, a, input, select, [data-no-drag]')) return;
94
+ last = { x: event.clientX, y: event.clientY };
95
+ dragged = false;
96
+ capture(event);
97
+ }
98
+ function ondragmove(event: PointerEvent): void {
99
+ if (!last) return;
100
+ const dx = event.clientX - last.x;
101
+ const dy = event.clientY - last.y;
102
+ if (!dragged && Math.abs(dx) <= DRAG_THRESHOLD && Math.abs(dy) <= DRAG_THRESHOLD) return;
103
+ dragged = true;
104
+ surface.moving = true;
105
+ last = { x: event.clientX, y: event.clientY };
106
+ surface.moveBy(dx, dy);
107
+ dockHint = dockable && isDockDrop(last, viewport());
108
+ }
109
+ function ondragup(event: PointerEvent): void {
110
+ if (!last) return;
111
+ const pointer = { x: event.clientX, y: event.clientY };
112
+ last = null;
113
+ dockHint = false;
114
+ release(event);
115
+ if (!dragged) return;
116
+ surface.commit();
117
+ if (dockable && isDockDrop(pointer, viewport())) surface.dock();
118
+ }
119
+
120
+ // --- resize from the edges ----------------------------------------------------------------
121
+ let resizing: { direction: ResizeDirection; last: Point } | null = null;
122
+ function onresizedown(event: PointerEvent, direction: ResizeDirection): void {
123
+ if (event.button !== 0) return;
124
+ resizing = { direction, last: { x: event.clientX, y: event.clientY } };
125
+ surface.moving = true;
126
+ capture(event);
127
+ event.preventDefault();
128
+ }
129
+ function onresizemove(event: PointerEvent): void {
130
+ if (!resizing) return;
131
+ const dx = event.clientX - resizing.last.x;
132
+ const dy = event.clientY - resizing.last.y;
133
+ resizing.last = { x: event.clientX, y: event.clientY };
134
+ surface.resizeBy(resizing.direction, dx, dy);
135
+ }
136
+ function onresizeup(event: PointerEvent): void {
137
+ if (!resizing) return;
138
+ resizing = null;
139
+ release(event);
140
+ surface.commit();
141
+ }
142
+
143
+ const HANDLE_CLASS: Record<ResizeDirection, string> = {
144
+ n: 'inset-x-3 top-0 h-1.5 cursor-ns-resize',
145
+ s: 'inset-x-3 bottom-0 h-1.5 cursor-ns-resize',
146
+ e: 'inset-y-3 right-0 w-1.5 cursor-ew-resize',
147
+ w: 'inset-y-3 left-0 w-1.5 cursor-ew-resize',
148
+ ne: 'right-0 top-0 size-3 cursor-nesw-resize',
149
+ nw: 'left-0 top-0 size-3 cursor-nwse-resize',
150
+ se: 'bottom-0 right-0 size-3 cursor-nwse-resize',
151
+ sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
152
+ };
153
+
154
+ // --- keyboard ------------------------------------------------------------------------------
155
+ const ARROWS: Record<string, Point> = {
156
+ ArrowLeft: { x: -1, y: 0 },
157
+ ArrowRight: { x: 1, y: 0 },
158
+ ArrowUp: { x: 0, y: -1 },
159
+ ArrowDown: { x: 0, y: 1 },
160
+ };
161
+ function onhandlekeydown(event: KeyboardEvent): void {
162
+ const arrow = ARROWS[event.key];
163
+ if (!arrow) return;
164
+ event.preventDefault();
165
+ const step = event.altKey ? 4 : KEY_STEP;
166
+ if (event.shiftKey) {
167
+ if (!resizable) return;
168
+ surface.resizeBy('se', arrow.x * step, arrow.y * step);
169
+ } else surface.moveBy(arrow.x * step, arrow.y * step);
170
+ surface.commit();
171
+ }
172
+ function onkeydown(event: KeyboardEvent): void {
173
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
174
+ // A menu, popover or dialog opened from inside the window closes first.
175
+ if (
176
+ document.querySelector(
177
+ '[data-state="open"]:is([role="menu"], [role="listbox"], [role="dialog"], [role="alertdialog"])',
178
+ )
179
+ )
180
+ return;
181
+ event.preventDefault();
182
+ minimize();
183
+ }
184
+ </script>
185
+
186
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_noninteractive_element_to_interactive_role -->
187
+ <section
188
+ bind:this={root}
189
+ {id}
190
+ tabindex="-1"
191
+ role="dialog"
192
+ aria-modal="false"
193
+ aria-labelledby="{id}-title"
194
+ class={cn(
195
+ 'fixed z-40 flex flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-2xl outline-none',
196
+ surface.moving && 'select-none',
197
+ dockHint && 'ring-2 ring-primary',
198
+ className,
199
+ )}
200
+ style:left="{rect.x}px"
201
+ style:top="{rect.y}px"
202
+ style:width="{rect.width}px"
203
+ style:height="{rect.height}px"
204
+ data-agent-window
205
+ data-moving={surface.moving ? '' : undefined}
206
+ {onkeydown}
207
+ >
208
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
209
+ <div
210
+ class={cn(
211
+ 'flex h-12 shrink-0 touch-none items-center gap-1 border-b border-border bg-muted/40 pl-1 pr-2',
212
+ surface.moving ? 'cursor-grabbing' : 'cursor-grab',
213
+ )}
214
+ data-testid="window-header"
215
+ onpointerdown={ondragdown}
216
+ onpointermove={ondragmove}
217
+ onpointerup={ondragup}
218
+ onpointercancel={ondragup}
219
+ >
220
+ <button
221
+ type="button"
222
+ class="flex size-8 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
223
+ aria-label={resizable
224
+ ? 'Move window: arrow keys move, Shift and arrow keys resize'
225
+ : 'Move window: arrow keys move'}
226
+ data-no-drag
227
+ onkeydown={onhandlekeydown}
228
+ >
229
+ <Icon name="grip" size={14} />
230
+ </button>
231
+ {#if icon}
232
+ {@render icon()}
233
+ {:else}
234
+ <span
235
+ class="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
236
+ aria-hidden="true"
237
+ >
238
+ <Icon name="sparkles" size={14} />
239
+ </span>
240
+ {/if}
241
+ <div class="ml-1 flex min-w-0 flex-1 flex-col leading-tight">
242
+ <h2 id="{id}-title" class="truncate text-sm font-semibold">{title}</h2>
243
+ {#if subtitle}
244
+ <span class="truncate text-[11px] text-muted-foreground" data-testid="window-subtitle"
245
+ >{subtitle}</span
246
+ >
247
+ {/if}
248
+ </div>
249
+ {@render actions?.()}
250
+ {#if dockable}
251
+ <Button
252
+ variant="ghost"
253
+ size="sm"
254
+ aria-label="Dock to the side"
255
+ onclick={() => surface.dock()}
256
+ >
257
+ <Icon name="sidebar" size={14} />
258
+ </Button>
259
+ {/if}
260
+ <Button variant="ghost" size="sm" aria-label="Minimize" onclick={minimize}>
261
+ <Icon name="minus" size={14} />
262
+ </Button>
263
+ </div>
264
+ <div class={cn('min-h-0 flex-1', surface.moving && 'pointer-events-none')}>
265
+ {@render children()}
266
+ </div>
267
+ {#if resizable}
268
+ {#each RESIZE_DIRECTIONS as direction (direction)}
269
+ <div
270
+ class={cn('absolute touch-none', HANDLE_CLASS[direction])}
271
+ aria-hidden="true"
272
+ data-resize={direction}
273
+ onpointerdown={(event) => onresizedown(event, direction)}
274
+ onpointermove={onresizemove}
275
+ onpointerup={onresizeup}
276
+ onpointercancel={onresizeup}
277
+ ></div>
278
+ {/each}
279
+ {/if}
280
+ </section>
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The detached chat window: a non-modal floating panel the user can drag by its header and
3
+ * resize from any edge, so the page under it stays usable. Geometry lives in the `ChatSurface`
4
+ * it is given. Keyboard: the move handle takes arrow keys (Shift resizes, Alt for small
5
+ * steps); Escape inside the window minimizes it unless something inside used the key.
6
+ */
7
+ import { type Snippet } from 'svelte';
8
+ import type { ChatSurface } from '../surface.svelte.js';
9
+ interface Props {
10
+ surface: ChatSurface;
11
+ title?: string;
12
+ /** A second, quieter line under the title, e.g. the conversation's name. */
13
+ subtitle?: string;
14
+ /** Replaces the mark before the title. */
15
+ icon?: Snippet;
16
+ id?: string;
17
+ /** Header buttons before Dock and Minimize, e.g. New chat. */
18
+ actions?: Snippet;
19
+ children: Snippet;
20
+ /** Offer docking (button, and dropping the window on the right edge). */
21
+ dockable?: boolean;
22
+ resizable?: boolean;
23
+ /** Called after the window minimizes, e.g. to move focus to the launcher. */
24
+ onminimize?: () => void;
25
+ /**
26
+ * Take the keyboard when the window appears and nothing inside has it yet: the first text
27
+ * field, or the window itself. Without it Escape and Tab start from the page underneath.
28
+ */
29
+ autofocus?: boolean;
30
+ class?: string;
31
+ }
32
+ declare const FloatingWindow: import("svelte").Component<Props, {}, "">;
33
+ type FloatingWindow = ReturnType<typeof FloatingWindow>;
34
+ export default FloatingWindow;
35
+ //# sourceMappingURL=FloatingWindow.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FloatingWindow.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/FloatingWindow.svelte.ts"],"names":[],"mappings":"AAGA;;;;;KAKK;AACL,OAAO,EAAoB,KAAK,OAAO,EAAE,MAAM,QAAQ,CAAC;AAExD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAWtD,UAAU,KAAK;IACb,OAAO,EAAE,WAAW,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;IAClB,yEAAyE;IACzE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA2MH,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -70,6 +70,10 @@
70
70
  copy.setAttribute('aria-label', lang ? `Copy ${lang} code` : 'Copy code');
71
71
  copy.innerHTML = `${COPY_ICON}<span>Copy</span>`;
72
72
  header.append(label, copy);
73
+ // Long lines scroll sideways: keyboard users need to reach (and name) the scroller.
74
+ pre.tabIndex = 0;
75
+ pre.setAttribute('role', 'region');
76
+ pre.setAttribute('aria-label', lang ? `${lang} code` : 'Code');
73
77
  pre.replaceWith(wrap);
74
78
  wrap.append(header, pre);
75
79
  }
@@ -1 +1 @@
1
- {"version":3,"file":"Markdown.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/Markdown.svelte.ts"],"names":[],"mappings":"AA2CE;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAqBnD;AAoBD,UAAU,KAAK;IACb,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACvC;AAiCH;;;;;;;GAOG;AACH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Markdown.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/Markdown.svelte.ts"],"names":[],"mappings":"AA2CE;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAyBnD;AAoBD,UAAU,KAAK;IACb,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACvC;AAiCH;;;;;;;GAOG;AACH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -29,14 +29,21 @@
29
29
  document.getElementById(noteId)?.focus();
30
30
  }
31
31
  }
32
+ let group = $state<HTMLSpanElement | null>(null);
33
+ /** Close the note and put the keyboard back on the thumbs-down it came from. */
34
+ async function closeNote(): Promise<void> {
35
+ asking = false;
36
+ await tick();
37
+ group?.querySelector<HTMLButtonElement>('button[aria-label="Bad answer"]')?.focus();
38
+ }
32
39
  async function sendNote(): Promise<void> {
33
40
  const text = note.trim();
34
- asking = false;
41
+ void closeNote();
35
42
  if (text) await onrate('down', text);
36
43
  }
37
44
  </script>
38
45
 
39
- <span class="inline-flex items-center" role="group" aria-label="Rate this answer">
46
+ <span bind:this={group} class="inline-flex items-center" role="group" aria-label="Rate this answer">
40
47
  <Button
41
48
  variant="ghost"
42
49
  size="icon"
@@ -79,11 +86,11 @@
79
86
  onkeydown={(e: KeyboardEvent) => {
80
87
  if (e.key === 'Escape') {
81
88
  e.preventDefault();
82
- asking = false;
89
+ void closeNote();
83
90
  }
84
91
  }}
85
92
  />
86
93
  <Button type="submit" size="sm" variant="outline" disabled={!note.trim()}>Send</Button>
87
- <Button type="button" size="sm" variant="ghost" onclick={() => (asking = false)}>Skip</Button>
94
+ <Button type="button" size="sm" variant="ghost" onclick={() => void closeNote()}>Skip</Button>
88
95
  </form>
89
96
  {/if}
@@ -1 +1 @@
1
- {"version":3,"file":"MessageFeedback.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageFeedback.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAK/D,UAAU,KAAK;IACb,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,MAAM,EAAE,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/E;AAyDH;;;GAGG;AACH,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"MessageFeedback.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageFeedback.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAK/D,UAAU,KAAK;IACb,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,MAAM,EAAE,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/E;AAgEH;;;GAGG;AACH,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}