@conscience-technology/ctds 0.1.9 → 0.1.10

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 (76) hide show
  1. package/assets/components/AIMessage/AIMessage.module.css +117 -0
  2. package/assets/components/AIMessage/AIMessage.tsx +90 -0
  3. package/assets/components/AIMessage/index.ts +2 -0
  4. package/assets/components/AttachmentChip/AttachmentChip.module.css +3 -1
  5. package/assets/components/AttachmentThumb/AttachmentThumb.module.css +13 -7
  6. package/assets/components/AttachmentThumb/AttachmentThumb.tsx +4 -1
  7. package/assets/components/Bubble/Bubble.module.css +65 -0
  8. package/assets/components/Bubble/Bubble.tsx +63 -0
  9. package/assets/components/Bubble/index.ts +2 -0
  10. package/assets/components/Button/Button.module.css +15 -2
  11. package/assets/components/Button/Button.tsx +13 -0
  12. package/assets/components/Calendar/Calendar.module.css +163 -0
  13. package/assets/components/Calendar/Calendar.tsx +323 -0
  14. package/assets/components/Calendar/index.ts +2 -0
  15. package/assets/components/ChatInput/ChatInput.module.css +82 -45
  16. package/assets/components/ChatInput/ChatInput.tsx +46 -34
  17. package/assets/components/ChatInput/ModelSelector.tsx +23 -27
  18. package/assets/components/ChatInput/index.ts +0 -1
  19. package/assets/components/CodeBlock/CodeBlock.module.css +79 -0
  20. package/assets/components/CodeBlock/CodeBlock.tsx +71 -0
  21. package/assets/components/CodeBlock/index.ts +2 -0
  22. package/assets/components/ContextMenu/ContextMenu.tsx +10 -1
  23. package/assets/components/DropdownMenu/DropdownMenu.module.css +26 -0
  24. package/assets/components/DropdownMenu/DropdownMenu.tsx +367 -0
  25. package/assets/components/DropdownMenu/index.ts +9 -0
  26. package/assets/components/EventChip/EventChip.module.css +57 -0
  27. package/assets/components/EventChip/EventChip.tsx +41 -0
  28. package/assets/components/EventChip/index.ts +2 -0
  29. package/assets/components/Icon/Icon.tsx +3 -0
  30. package/assets/components/IdentityButton/IdentityButton.module.css +119 -0
  31. package/assets/components/IdentityButton/IdentityButton.tsx +113 -0
  32. package/assets/components/IdentityButton/index.ts +6 -0
  33. package/assets/components/MenuItem/MenuItem.module.css +67 -2
  34. package/assets/components/MenuItem/MenuItem.tsx +22 -2
  35. package/assets/components/MessageActions/MessageActions.module.css +43 -0
  36. package/assets/components/MessageActions/MessageActions.tsx +94 -0
  37. package/assets/components/MessageActions/index.ts +6 -0
  38. package/assets/components/MessageQuote/MessageQuote.module.css +18 -0
  39. package/assets/components/MessageQuote/MessageQuote.tsx +33 -0
  40. package/assets/components/MessageQuote/index.ts +2 -0
  41. package/assets/components/MessageSources/MessageSources.module.css +63 -0
  42. package/assets/components/MessageSources/MessageSources.tsx +50 -0
  43. package/assets/components/MessageSources/index.ts +2 -0
  44. package/assets/components/MessageTable/MessageTable.module.css +46 -0
  45. package/assets/components/MessageTable/MessageTable.tsx +21 -0
  46. package/assets/components/MessageTable/index.ts +2 -0
  47. package/assets/components/QuotationBar/QuotationBar.module.css +2 -2
  48. package/assets/components/Sidebar/Sidebar.module.css +3 -57
  49. package/assets/components/Sidebar/Sidebar.tsx +18 -21
  50. package/assets/components/Spinner/LottiePlayer.tsx +75 -0
  51. package/assets/components/Spinner/Spinner.module.css +167 -0
  52. package/assets/components/Spinner/Spinner.tsx +119 -0
  53. package/assets/components/Spinner/index.ts +10 -0
  54. package/assets/components/Spinner/lottieColor.ts +76 -0
  55. package/assets/components/Spinner/lottieManifest.ts +99 -0
  56. package/assets/components/Tabs/Tabs.module.css +3 -3
  57. package/assets/components/UserMessage/UserMessage.module.css +41 -0
  58. package/assets/components/UserMessage/UserMessage.tsx +66 -0
  59. package/assets/components/UserMessage/index.ts +2 -0
  60. package/assets/components/_data.ts +18 -4
  61. package/assets/components/tokens.css +20 -0
  62. package/assets/prompts/components/ai-message.md +43 -0
  63. package/assets/prompts/components/bubble.md +56 -0
  64. package/assets/prompts/components/calendar.md +123 -0
  65. package/assets/prompts/components/code-block.md +35 -0
  66. package/assets/prompts/components/dropdown-menu.md +145 -0
  67. package/assets/prompts/components/identity-button.md +168 -0
  68. package/assets/prompts/components/message-actions.md +49 -0
  69. package/assets/prompts/components/message-quote.md +26 -0
  70. package/assets/prompts/components/message-sources.md +26 -0
  71. package/assets/prompts/components/message-table.md +27 -0
  72. package/assets/prompts/components/sidebar.md +48 -3
  73. package/assets/prompts/components/spinner.md +109 -0
  74. package/assets/prompts/components/user-message.md +45 -0
  75. package/assets/prompts/primitives/scroll.md +22 -11
  76. package/package.json +1 -1
@@ -0,0 +1,323 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ memo,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type HTMLAttributes,
11
+ type ReactNode,
12
+ } from "react";
13
+
14
+ import { Badge } from "../Badge";
15
+ import { Button } from "../Button";
16
+ import { EventChip, type EventChipTone } from "../EventChip";
17
+ import { Icon } from "../Icon";
18
+ import { IconButton } from "../IconButton";
19
+ import {
20
+ addMonths,
21
+ buildMonthGrid,
22
+ isSameDay,
23
+ isSameMonth,
24
+ formatISODate,
25
+ MONTH_LABELS_KO,
26
+ WEEKDAY_LABELS_KO,
27
+ startOfMonth,
28
+ } from "../DatePicker/utils";
29
+ import styles from "./Calendar.module.css";
30
+
31
+ export type CalendarEventTone = EventChipTone;
32
+
33
+ export type CalendarEvent = {
34
+ id: string;
35
+ /** ISO yyyy-MM-dd */
36
+ date: string;
37
+ title: string;
38
+ tone?: CalendarEventTone;
39
+ };
40
+
41
+ export type CalendarProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
42
+ /** 현재 보여줄 달. controlled. */
43
+ month: Date;
44
+ /** 오늘 날짜. 테스트/스토리북에서 고정하려면 넘김. 기본 new Date(). */
45
+ today?: Date;
46
+ /** 선택된 날짜. 셀 하이라이트. */
47
+ selectedDate?: Date | null;
48
+ events?: CalendarEvent[];
49
+ /** 요일 시작. 0=일, 1=월. 기본 1 (업무 캘린더 관행). */
50
+ weekStartsOn?: 0 | 1;
51
+ showWeekend?: boolean;
52
+ /** 상단바 왼쪽 커스텀 슬롯 (예: Google 연결 상태). */
53
+ leadingSlot?: ReactNode;
54
+ /** 상단바 오른쪽 "오늘" 버튼 표시 여부. 기본 true. */
55
+ showTodayButton?: boolean;
56
+ onMonthChange?: (next: Date) => void;
57
+ onSelectedDateChange?: (next: Date) => void;
58
+ };
59
+
60
+ const Calendar = forwardRef<HTMLDivElement, CalendarProps>(function Calendar(
61
+ {
62
+ className,
63
+ month,
64
+ today: todayProp,
65
+ selectedDate = null,
66
+ events = [],
67
+ weekStartsOn = 1,
68
+ showWeekend = true,
69
+ leadingSlot,
70
+ showTodayButton = true,
71
+ onMonthChange,
72
+ onSelectedDateChange,
73
+ ...rest
74
+ },
75
+ ref,
76
+ ) {
77
+ const today = useMemo(() => todayProp ?? new Date(), [todayProp]);
78
+ const monthStart = useMemo(() => startOfMonth(month), [month]);
79
+ const isTodayMonth = isSameMonth(monthStart, today);
80
+
81
+ const allDays = useMemo(
82
+ () => buildMonthGrid(monthStart, weekStartsOn),
83
+ [monthStart, weekStartsOn],
84
+ );
85
+ const weeks = allDays.length / 7;
86
+
87
+ const weekdays = useMemo(() => {
88
+ const list = [...WEEKDAY_LABELS_KO];
89
+ for (let i = 0; i < weekStartsOn; i += 1) list.push(list.shift() as string);
90
+ return list;
91
+ }, [weekStartsOn]);
92
+
93
+ const visibleWeekdays = showWeekend ? weekdays : weekdays.filter((_, i) => !isWeekendIndex(i, weekStartsOn));
94
+ const visibleDays = showWeekend
95
+ ? allDays
96
+ : allDays.filter((d) => d.getDay() !== 0 && d.getDay() !== 6);
97
+
98
+ const eventsByDate = useMemo(() => groupEventsByDate(events), [events]);
99
+
100
+ const shiftMonth = (delta: number) => onMonthChange?.(addMonths(monthStart, delta));
101
+ const shiftYear = (delta: number) => {
102
+ const next = new Date(monthStart);
103
+ next.setFullYear(next.getFullYear() + delta);
104
+ onMonthChange?.(next);
105
+ };
106
+
107
+ const columns = showWeekend ? 7 : 5;
108
+
109
+ return (
110
+ <div
111
+ ref={ref}
112
+ className={[styles.root, className].filter(Boolean).join(" ")}
113
+ {...rest}
114
+ >
115
+ <div className={styles.toolbar}>
116
+ <div className={styles.toolbarSide}>{leadingSlot}</div>
117
+
118
+ <div className={styles.monthNav}>
119
+ <IconButton
120
+ variant="ghost"
121
+ size="sm"
122
+ aria-label="이전 해"
123
+ icon={<Icon name="caret-double-left" />}
124
+ onClick={() => shiftYear(-1)}
125
+ />
126
+ <IconButton
127
+ variant="ghost"
128
+ size="sm"
129
+ aria-label="이전 달"
130
+ icon={<Icon name="caret-left" />}
131
+ onClick={() => shiftMonth(-1)}
132
+ />
133
+ <span className={styles.monthTitle}>
134
+ {monthStart.getFullYear()}년 {MONTH_LABELS_KO[monthStart.getMonth()]}
135
+ </span>
136
+ <IconButton
137
+ variant="ghost"
138
+ size="sm"
139
+ aria-label="다음 달"
140
+ icon={<Icon name="caret-right" />}
141
+ onClick={() => shiftMonth(1)}
142
+ />
143
+ <IconButton
144
+ variant="ghost"
145
+ size="sm"
146
+ aria-label="다음 해"
147
+ icon={<Icon name="caret-double-right" />}
148
+ onClick={() => shiftYear(1)}
149
+ />
150
+ </div>
151
+
152
+ <div className={styles.toolbarSide} data-align="end">
153
+ {showTodayButton && (
154
+ <div data-visible={(!isTodayMonth) || undefined} className={styles.todaySlot}>
155
+ <Button
156
+ variant="outline"
157
+ size="sm"
158
+ onClick={() => {
159
+ onMonthChange?.(startOfMonth(today));
160
+ onSelectedDateChange?.(today);
161
+ }}
162
+ >
163
+ 오늘
164
+ </Button>
165
+ </div>
166
+ )}
167
+ </div>
168
+ </div>
169
+
170
+ <div
171
+ className={styles.weekdays}
172
+ style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
173
+ >
174
+ {visibleWeekdays.map((w) => (
175
+ <div
176
+ key={w}
177
+ className={styles.weekday}
178
+ data-tone={weekdayTone(w)}
179
+ >
180
+ {w}
181
+ </div>
182
+ ))}
183
+ </div>
184
+
185
+ <div
186
+ className={styles.grid}
187
+ style={{
188
+ gridTemplateColumns: `repeat(${columns}, 1fr)`,
189
+ gridTemplateRows: `repeat(${weeks}, minmax(0, 1fr))`,
190
+ }}
191
+ >
192
+ {visibleDays.map((day) => {
193
+ const key = formatISODate(day);
194
+ return (
195
+ <DayCell
196
+ key={key}
197
+ day={day}
198
+ month={monthStart}
199
+ today={today}
200
+ events={eventsByDate.get(key) ?? []}
201
+ isSelected={!!selectedDate && isSameDay(day, selectedDate)}
202
+ onSelect={() => onSelectedDateChange?.(day)}
203
+ />
204
+ );
205
+ })}
206
+ </div>
207
+ </div>
208
+ );
209
+ });
210
+
211
+ export { Calendar };
212
+
213
+ /* ───────────────────────── DayCell ───────────────────────── */
214
+
215
+ type DayCellProps = {
216
+ day: Date;
217
+ month: Date;
218
+ today: Date;
219
+ events: CalendarEvent[];
220
+ isSelected: boolean;
221
+ onSelect: () => void;
222
+ };
223
+
224
+ const DayCell = memo(function DayCell({
225
+ day,
226
+ month,
227
+ today,
228
+ events,
229
+ isSelected,
230
+ onSelect,
231
+ }: DayCellProps) {
232
+ const outside = !isSameMonth(day, month);
233
+ const isToday = isSameDay(day, today);
234
+ const isPast = day.getTime() < new Date(today).setHours(0, 0, 0, 0);
235
+ const dow = day.getDay();
236
+ const tone = outside || isPast ? "muted" : dow === 0 ? "sun" : dow === 6 ? "sat" : "default";
237
+
238
+ const eventsRef = useRef<HTMLDivElement>(null);
239
+ const [hiddenCount, setHiddenCount] = useState(0);
240
+
241
+ useLayoutEffect(() => {
242
+ const container = eventsRef.current;
243
+ if (!container) return;
244
+ const recalc = () => {
245
+ const containerBottom = container.getBoundingClientRect().bottom;
246
+ const children = Array.from(container.children) as HTMLElement[];
247
+ let visible = 0;
248
+ for (const child of children) {
249
+ if (child.getBoundingClientRect().bottom <= containerBottom + 1) {
250
+ visible += 1;
251
+ } else {
252
+ break;
253
+ }
254
+ }
255
+ setHiddenCount(Math.max(0, events.length - visible));
256
+ };
257
+ recalc();
258
+ const ro = new ResizeObserver(recalc);
259
+ ro.observe(container);
260
+ return () => ro.disconnect();
261
+ }, [events.length]);
262
+
263
+ return (
264
+ <div
265
+ className={styles.cell}
266
+ data-outside={outside || undefined}
267
+ data-today={isToday || undefined}
268
+ data-selected={isSelected || undefined}
269
+ role="button"
270
+ tabIndex={0}
271
+ onClick={onSelect}
272
+ onKeyDown={(e) => {
273
+ if (e.key === "Enter" || e.key === " ") {
274
+ e.preventDefault();
275
+ onSelect();
276
+ }
277
+ }}
278
+ >
279
+ <div className={styles.cellHead}>
280
+ <span className={styles.dayNum} data-tone={tone} data-selected={isSelected || undefined}>
281
+ {day.getDate()}
282
+ </span>
283
+ <span className={styles.cellBadges}>
284
+ {hiddenCount > 0 && <Badge size="sm" variant="soft">+{hiddenCount}</Badge>}
285
+ {isToday && <Badge size="sm" variant="soft">오늘</Badge>}
286
+ </span>
287
+ </div>
288
+ <div ref={eventsRef} className={styles.cellEvents}>
289
+ {events.map((ev) => (
290
+ <EventChip
291
+ key={ev.id}
292
+ title={ev.title}
293
+ tone={ev.tone}
294
+ faded={outside}
295
+ />
296
+ ))}
297
+ </div>
298
+ </div>
299
+ );
300
+ });
301
+
302
+ /* ───────────────────────── helpers ───────────────────────── */
303
+
304
+ function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
305
+ const map = new Map<string, CalendarEvent[]>();
306
+ for (const ev of events) {
307
+ const list = map.get(ev.date) ?? [];
308
+ list.push(ev);
309
+ map.set(ev.date, list);
310
+ }
311
+ return map;
312
+ }
313
+
314
+ function isWeekendIndex(i: number, weekStartsOn: 0 | 1): boolean {
315
+ if (weekStartsOn === 0) return i === 0 || i === 6;
316
+ return i === 5 || i === 6;
317
+ }
318
+
319
+ function weekdayTone(label: string): "sat" | "sun" | "default" {
320
+ if (label === "토") return "sat";
321
+ if (label === "일") return "sun";
322
+ return "default";
323
+ }
@@ -0,0 +1,2 @@
1
+ export { Calendar } from "./Calendar";
2
+ export type { CalendarProps, CalendarEvent, CalendarEventTone } from "./Calendar";
@@ -8,7 +8,8 @@
8
8
  gap: 0;
9
9
  background: var(--bg);
10
10
  border: var(--hairline) solid var(--border);
11
- border-radius: var(--radius-sm);
11
+ border-radius: var(--radius-xl);
12
+ box-shadow: var(--shadow-md);
12
13
  transition: var(--transition-colors), var(--transition-ring);
13
14
  overflow: hidden; /* root border radius로 자식 clip (scroll 끝이 root border에서) */
14
15
  }
@@ -20,44 +21,43 @@
20
21
  padding: 0;
21
22
  }
22
23
 
23
- /* radius variant — Shape 축에 반응 */
24
- .radius-square { border-radius: var(--radius-xs); }
25
- .radius-rounded { border-radius: var(--radius-sm); }
26
- .radius-circular { border-radius: var(--radius-md); }
27
-
28
24
  .root:focus-within {
29
- box-shadow: var(--ring);
25
+ box-shadow: var(--ring), var(--shadow-md);
30
26
  }
31
27
  .disabled {
32
28
  opacity: 0.5;
33
29
  pointer-events: none;
34
30
  }
35
31
 
36
- /* 첨부물 바 — 3-column grid: chip 고정폭(한 줄 3개).
32
+ /* 첨부물 바 — 가로 wrap 스택.
37
33
  gap space-1 (feedback-small-component-gap). */
38
34
  .attachments {
39
- display: grid;
40
- grid-template-columns: repeat(3, minmax(0, 1fr));
35
+ display: flex;
36
+ flex-wrap: wrap;
41
37
  gap: var(--space-1);
42
- padding: var(--space-3) var(--space-4) var(--space-1);
38
+ padding: var(--space-2);
39
+ }
40
+ /* SingleLine은 아래 body footer가 자체 상단 padding을 가지므로 bottom 압축. */
41
+ .shape-single .attachments {
42
+ padding-bottom: var(--space-0_5);
43
43
  }
44
- /* Thumb은 셀 안에서 자체 폭 유지(좌측 정렬), chip은 셀 폭 채움. */
45
44
  .attachmentCell {
46
45
  display: flex;
47
46
  min-width: 0;
48
47
  }
49
- .attachmentCell[data-kind="image"] > * {
50
- flex: 0 0 auto;
51
- }
48
+ .attachmentCell[data-kind="image"] > *,
52
49
  .attachmentCell[data-kind="file"] > * {
53
- width: 100%;
54
- max-width: 100%;
50
+ flex: 0 0 auto;
55
51
  }
56
52
 
57
53
  /* Quotation */
58
54
  .quotation {
59
55
  display: block;
60
- padding: var(--space-3) var(--space-4) var(--space-1);
56
+ padding: var(--space-2);
57
+ }
58
+ /* SingleLine은 아래 body footer가 자체 상단 padding을 가지므로 bottom 압축. */
59
+ .shape-single .quotation {
60
+ padding-bottom: var(--space-0_5);
61
61
  }
62
62
  /* attachments 아래에 quotation이 오면 상단 패딩 제거 (attachments bottom이 책임) */
63
63
  .attachments + .quotation {
@@ -81,30 +81,25 @@
81
81
  width: 100%;
82
82
  min-width: 0;
83
83
  }
84
- /* multiScroll — 세로 스크롤 컨테이너. root border까지 닿음(root padding 0).
85
- 스크롤 룰:
86
- - padding-top은 wrapper, padding-bottom은 textarea 내부에 (크로스브라우저 scroll padding 이슈 회피).
87
- - 짧은 글: wrapper 안에서 스크롤 없음 → padding이 "숨 공간"처럼 보임.
88
- - 긴 글: wrapper scroll 발생 → 글이 상·하 padding 영역을 통과해 root border에서 자연스레 잘림. */
84
+ /* multiScroll — 세로 스크롤 컨테이너.
85
+ **스크롤 룰**: wrapper에 padding 0, textarea에 상·하 padding + 좌·우 padding 둘 다.
86
+ textarea가 자신의 padding을 content의 일부로 품으므로 scroll 시 글이 상·하 padding 영역을
87
+ 통과해 root border에서 clip. wrapper에 padding을 두면 scroll 시 그 영역이 "빈 띠"로 남음. */
89
88
  .multiScroll {
90
89
  overflow-y: auto;
91
90
  max-height: calc(var(--text-sm) * 1.5 * 8); /* ~8 lines */
92
- padding: var(--space-3) var(--space-4) 0;
93
- }
94
- /* attachments/quotation 아래에 오면 상단 패딩 절반 (이미 attachments/quotation에 bottom padding 있음) */
95
- .attachments + .body .multiScroll,
96
- .quotation + .body .multiScroll {
97
- padding-top: var(--space-1);
91
+ padding: 0;
92
+ scroll-padding-bottom: var(--space-3);
93
+ scroll-padding-top: var(--space-3);
98
94
  }
99
95
 
100
- /* textarea는 flat + bottom 패딩만 — padding-bottom이 scroll content의 일부가 되어
101
- 스크롤 끝에서 글이 wrapper(=root) border까지 흘러감. */
96
+ /* textarea에 상·하·좌·우 padding 전부 — 스크롤되면 함께 올라감. */
102
97
  .multiField {
103
98
  display: block;
104
99
  width: 100%;
105
100
  min-width: 0;
106
101
  min-height: calc(var(--text-sm) * 1.5);
107
- padding: 0 0 var(--space-3);
102
+ padding: var(--space-3) var(--space-4);
108
103
  background: transparent;
109
104
  border: 0;
110
105
  outline: none;
@@ -114,10 +109,16 @@
114
109
  font-size: var(--text-sm);
115
110
  line-height: 1.5;
116
111
  overflow: hidden;
112
+ box-sizing: border-box;
117
113
  }
118
114
  .multiField::placeholder {
119
115
  color: var(--fg-muted);
120
116
  }
117
+ /* attachments/quotation 아래에선 상단 padding 압축 (이미 attachments/quotation이 상단 책임). */
118
+ .attachments + .body .multiField,
119
+ .quotation + .body .multiField {
120
+ padding-top: var(--space-1);
121
+ }
121
122
 
122
123
  .footer {
123
124
  display: flex;
@@ -127,32 +128,63 @@
127
128
  padding: 0 var(--space-2) var(--space-2);
128
129
  }
129
130
 
130
- /* SingleLine — Shift+Enter로 자라면 textarea 상단으로 확장, 좌/우 버튼은 하단 고정.
131
- MultiLine과 동일 max-height(≈8줄)에서 스크롤. */
131
+ /* SingleLine — body는 relative + padding 0, 버튼은 absolute로 좌·우 하단 고정.
132
+ singleScroll이 body 전체 높이·폭 차지하되 좌·우 padding으로 버튼 자리 확보.
133
+ 이러면 body 높이가 전적으로 textarea(singleScroll) 크기에 따라감 — padding 늘리면 input도 커짐. */
132
134
  .shape-single .body {
133
135
  flex-direction: row;
134
- align-items: flex-end; /* 버튼들 하단 고정 */
135
- gap: var(--space-0_5);
136
- padding: var(--space-2) var(--space-2);
136
+ align-items: stretch;
137
+ gap: 0;
138
+ padding: 0;
139
+ position: relative;
140
+ }
141
+ /* 버튼 bottom inset은 textarea 상·하 padding과 같은 토큰으로 — 1줄 상태에서
142
+ textarea 수직 중앙 = 버튼 수직 중앙 (density scale 바뀌어도 비례해서 유지됨). */
143
+ .shape-single .attachBtn {
144
+ position: absolute;
145
+ left: var(--space-2);
146
+ bottom: calc((var(--space-3) * 2 + var(--text-sm) * 1.5 - var(--space-8)) / 2);
147
+ z-index: var(--z-raise);
148
+ }
149
+ .shape-single .actions {
150
+ position: absolute;
151
+ right: var(--space-2);
152
+ bottom: calc((var(--space-3) * 2 + var(--text-sm) * 1.5 - var(--space-8)) / 2);
153
+ z-index: var(--z-raise);
154
+ }
155
+ /* singleScroll — body 전체 차지. 좌·우 padding으로 버튼 자리 확보.
156
+ 왼쪽: attachBtn 32 + margin 8 = 40 (gap 없음).
157
+ 오른쪽: actions(model+gap+send) 가변 + margin 8. */
158
+ .shape-single .singleScroll {
159
+ padding-left: var(--space-10);
160
+ padding-right: calc(var(--space-20) + var(--space-8));
161
+ }
162
+ .singleScroll {
163
+ flex: 1 1 auto;
164
+ min-width: 0;
165
+ overflow-y: auto;
166
+ max-height: calc(var(--text-sm) * 1.5 * 8);
167
+ padding: 0;
168
+ /* 타이핑 중 caret이 하단에 올 때 scroll이 자동으로 textarea padding-bottom을 지나
169
+ root border까지 밀어주도록 scroll-padding 확보. */
170
+ scroll-padding-bottom: var(--space-3);
171
+ scroll-padding-top: var(--space-3);
137
172
  }
138
173
  .singleField {
139
- flex: 1 1 auto;
174
+ display: block;
175
+ width: 100%;
140
176
  min-width: 0;
141
- padding: calc((var(--space-8) - var(--text-sm) * 1.5) / 2) var(--space-1);
177
+ padding: var(--space-3) 0;
142
178
  background: transparent;
143
179
  border: 0;
144
180
  outline: none;
145
181
  resize: none;
182
+ overflow: hidden;
146
183
  color: var(--fg-strong);
147
184
  font: inherit;
148
185
  font-size: var(--text-sm);
149
186
  line-height: 1.5;
150
- /* MultiLine과 동일 상한 — 넘으면 textarea 자체 스크롤 */
151
- max-height: calc(var(--text-sm) * 1.5 * 8);
152
- overflow-y: auto;
153
- }
154
- .singleField::placeholder {
155
- color: var(--fg-muted);
187
+ box-sizing: border-box;
156
188
  }
157
189
  .singleField::placeholder {
158
190
  color: var(--fg-muted);
@@ -272,6 +304,11 @@
272
304
  .sendBtn:disabled {
273
305
  cursor: not-allowed;
274
306
  }
307
+ /* 비활성(= 입력 비어있어 보낼 수 없음) 상태 아이콘을 연하게.
308
+ data-active일 땐 primary 배경이라 색 유지. */
309
+ .sendBtn:not([data-active]) {
310
+ color: var(--fg-disabled);
311
+ }
275
312
  .sendIcon {
276
313
  width: 44%;
277
314
  height: 44%;
@@ -9,17 +9,18 @@ import {
9
9
  type CSSProperties,
10
10
  type FormEvent,
11
11
  type KeyboardEvent,
12
+ type ReactNode,
12
13
  } from "react";
13
14
  import { Paperclip as PhPaperclip } from "@phosphor-icons/react";
14
15
  import { AttachmentChip } from "@/components/ctds/AttachmentChip";
15
16
  import { AttachmentThumb } from "@/components/ctds/AttachmentThumb";
17
+ import { DropdownMenu } from "@/components/ctds/DropdownMenu";
16
18
  import { QuotationBar } from "@/components/ctds/QuotationBar";
17
19
  import { ModelSelector } from "./ModelSelector";
18
20
  import { SendButton } from "./SendButton";
19
21
  import styles from "./ChatInput.module.css";
20
22
 
21
23
  export type ChatInputShape = "multi" | "single";
22
- export type ChatInputRadius = "square" | "rounded" | "circular";
23
24
 
24
25
  /** 첨부 공용 타입 — ChatInput·Message 양쪽에서 재사용. */
25
26
  export interface ChatAttachmentImage {
@@ -39,11 +40,14 @@ export type ChatAttachment = ChatAttachmentImage | ChatAttachmentFile;
39
40
  export interface ChatInputModel {
40
41
  id: string;
41
42
  label: string;
43
+ /** 메뉴 아이템 leading 슬롯. 모델별 대표 아이콘 (CTDS Icon 노드 등). */
44
+ icon?: ReactNode;
45
+ /** 2줄 아이템용 보조 문구. 넘기면 메뉴 아이템이 2줄로 열림. */
46
+ description?: ReactNode;
42
47
  }
43
48
 
44
49
  export interface ChatInputProps {
45
50
  shape?: ChatInputShape;
46
- radius?: ChatInputRadius;
47
51
 
48
52
  value: string;
49
53
  onChange: (v: string) => void;
@@ -81,7 +85,6 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
81
85
  function ChatInput(props, ref) {
82
86
  const {
83
87
  shape = "multi",
84
- radius = "rounded",
85
88
  value,
86
89
  onChange,
87
90
  onSend,
@@ -108,16 +111,14 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
108
111
  const canSend = !disabled && (value.trim().length > 0 || attachments.length > 0);
109
112
  const showStop = isStreaming && !!onStop;
110
113
 
111
- /* autoResize — multi/single 둘 다. textarea는 content에 맞춰 자람.
112
- Shift+Enter로 줄바꿈 → textarea 즉시 확장. max 넘으면 CSS max-height가 scroll 생성. */
114
+ /* autoResize — multi/single 둘 다. textarea는 content + padding에 맞춰 자람.
115
+ scrollHeight는 content + padding(border-box). height를 auto로 reset해야 축소도 가능. */
113
116
  useLayoutEffect(() => {
114
117
  const el = taRef.current;
115
118
  if (!el) return;
116
119
  el.style.height = "auto";
117
- // scrollHeight로 설정하되 CSS max-height가 override하도록 직접 min
118
- const nextH = el.scrollHeight;
119
- el.style.height = `${nextH}px`;
120
- }, [shape, value]);
120
+ el.style.height = `${el.scrollHeight}px`;
121
+ });
121
122
 
122
123
  const handleKeyDown = useCallback(
123
124
  (e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
@@ -166,7 +167,6 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
166
167
  const rootCls = [
167
168
  styles.root,
168
169
  styles[`shape-${shape}`],
169
- styles[`radius-${radius}`],
170
170
  disabled ? styles.disabled : "",
171
171
  className,
172
172
  ]
@@ -199,19 +199,29 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
199
199
 
200
200
  const ModelAndSend = (
201
201
  <div className={styles.actions}>
202
- {modelLabel ? (
203
- <ModelSelector
204
- label={modelLabel}
205
- compact={modelCompact}
206
- onClick={() => {
207
- if (!models || !onSelectModel) return;
208
- // 간단 토글 — 다음 모델. 실제 메뉴는 소비처에서 ContextMenu로.
209
- const idx = models.findIndex((m) => m.id === selectedModel);
210
- const next = models[(idx + 1) % models.length];
211
- onSelectModel(next.id);
212
- }}
213
- disabled={disabled}
214
- />
202
+ {modelLabel && models ? (
203
+ <DropdownMenu>
204
+ <DropdownMenu.Trigger asChild>
205
+ <ModelSelector
206
+ label={modelLabel}
207
+ compact={modelCompact}
208
+ disabled={disabled}
209
+ />
210
+ </DropdownMenu.Trigger>
211
+ <DropdownMenu.Content align="end">
212
+ <DropdownMenu.Label>모델 변경</DropdownMenu.Label>
213
+ {models.map((m) => (
214
+ <DropdownMenu.Item
215
+ key={m.id}
216
+ leadingActions={m.icon ? [{ node: m.icon, key: m.id }] : undefined}
217
+ description={m.description}
218
+ onSelect={() => onSelectModel?.(m.id)}
219
+ >
220
+ {m.label}
221
+ </DropdownMenu.Item>
222
+ ))}
223
+ </DropdownMenu.Content>
224
+ </DropdownMenu>
215
225
  ) : null}
216
226
  <SendButton
217
227
  active={canSend}
@@ -277,17 +287,19 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
277
287
  {shape === "single" ? (
278
288
  <>
279
289
  {AttachButton}
280
- <textarea
281
- ref={taRef}
282
- className={styles.singleField}
283
- value={value}
284
- onChange={(e) => onChange(e.target.value)}
285
- onKeyDown={handleKeyDown}
286
- placeholder={placeholder}
287
- disabled={disabled}
288
- rows={1}
289
- aria-label={placeholder}
290
- />
290
+ <div className={styles.singleScroll}>
291
+ <textarea
292
+ ref={taRef}
293
+ className={styles.singleField}
294
+ value={value}
295
+ onChange={(e) => onChange(e.target.value)}
296
+ onKeyDown={handleKeyDown}
297
+ placeholder={placeholder}
298
+ disabled={disabled}
299
+ rows={1}
300
+ aria-label={placeholder}
301
+ />
302
+ </div>
291
303
  {ModelAndSend}
292
304
  </>
293
305
  ) : (