@conscience-technology/ctds 0.1.8 → 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 (78) 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 +81 -41
  16. package/assets/components/ChatInput/ChatInput.tsx +45 -32
  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/menu-item.md +2 -1
  69. package/assets/prompts/components/message-actions.md +49 -0
  70. package/assets/prompts/components/message-quote.md +26 -0
  71. package/assets/prompts/components/message-sources.md +26 -0
  72. package/assets/prompts/components/message-table.md +27 -0
  73. package/assets/prompts/components/sidebar.md +106 -37
  74. package/assets/prompts/components/spinner.md +109 -0
  75. package/assets/prompts/components/user-message.md +45 -0
  76. package/assets/prompts/primitives/scroll.md +22 -11
  77. package/assets/skill/SKILL.md +1 -0
  78. 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,17 +128,53 @@
127
128
  padding: 0 var(--space-2) var(--space-2);
128
129
  }
129
130
 
130
- /* SingleLine */
131
+ /* SingleLine — body는 relative + padding 0, 버튼은 absolute로 좌·우 하단 고정.
132
+ singleScroll이 body 전체 높이·폭 차지하되 좌·우 padding으로 버튼 자리 확보.
133
+ 이러면 body 높이가 전적으로 textarea(singleScroll) 크기에 따라감 — padding 늘리면 input도 커짐. */
131
134
  .shape-single .body {
132
135
  flex-direction: row;
133
- align-items: center;
134
- gap: var(--space-0_5);
135
- 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);
136
172
  }
137
173
  .singleField {
138
- flex: 1 1 auto;
174
+ display: block;
175
+ width: 100%;
139
176
  min-width: 0;
140
- padding: 0 var(--space-1);
177
+ padding: var(--space-3) 0;
141
178
  background: transparent;
142
179
  border: 0;
143
180
  outline: none;
@@ -147,9 +184,7 @@
147
184
  font: inherit;
148
185
  font-size: var(--text-sm);
149
186
  line-height: 1.5;
150
- }
151
- .singleField::placeholder {
152
- color: var(--fg-muted);
187
+ box-sizing: border-box;
153
188
  }
154
189
  .singleField::placeholder {
155
190
  color: var(--fg-muted);
@@ -269,6 +304,11 @@
269
304
  .sendBtn:disabled {
270
305
  cursor: not-allowed;
271
306
  }
307
+ /* 비활성(= 입력 비어있어 보낼 수 없음) 상태 아이콘을 연하게.
308
+ data-active일 땐 primary 배경이라 색 유지. */
309
+ .sendBtn:not([data-active]) {
310
+ color: var(--fg-disabled);
311
+ }
272
312
  .sendIcon {
273
313
  width: 44%;
274
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,15 +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에 맞춰 자람(scroll 아님).
112
- 외부 scroll wrapper(multi)가 max-height 책임. single은 wrapper 없이 textarea가 직접 자람.
113
- Shift+Enter로 줄바꿈 → textarea가 즉시 자라고, SingleLine도 자연스레 늘어남. */
114
+ /* autoResize — multi/single 둘 다. textarea는 content + padding에 맞춰 자람.
115
+ scrollHeight는 content + padding(border-box). height를 auto로 reset해야 축소도 가능. */
114
116
  useLayoutEffect(() => {
115
117
  const el = taRef.current;
116
118
  if (!el) return;
117
119
  el.style.height = "auto";
118
120
  el.style.height = `${el.scrollHeight}px`;
119
- }, [shape, value]);
121
+ });
120
122
 
121
123
  const handleKeyDown = useCallback(
122
124
  (e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
@@ -165,7 +167,6 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
165
167
  const rootCls = [
166
168
  styles.root,
167
169
  styles[`shape-${shape}`],
168
- styles[`radius-${radius}`],
169
170
  disabled ? styles.disabled : "",
170
171
  className,
171
172
  ]
@@ -198,19 +199,29 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
198
199
 
199
200
  const ModelAndSend = (
200
201
  <div className={styles.actions}>
201
- {modelLabel ? (
202
- <ModelSelector
203
- label={modelLabel}
204
- compact={modelCompact}
205
- onClick={() => {
206
- if (!models || !onSelectModel) return;
207
- // 간단 토글 — 다음 모델. 실제 메뉴는 소비처에서 ContextMenu로.
208
- const idx = models.findIndex((m) => m.id === selectedModel);
209
- const next = models[(idx + 1) % models.length];
210
- onSelectModel(next.id);
211
- }}
212
- disabled={disabled}
213
- />
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>
214
225
  ) : null}
215
226
  <SendButton
216
227
  active={canSend}
@@ -276,17 +287,19 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
276
287
  {shape === "single" ? (
277
288
  <>
278
289
  {AttachButton}
279
- <textarea
280
- ref={taRef}
281
- className={styles.singleField}
282
- value={value}
283
- onChange={(e) => onChange(e.target.value)}
284
- onKeyDown={handleKeyDown}
285
- placeholder={placeholder}
286
- disabled={disabled}
287
- rows={1}
288
- aria-label={placeholder}
289
- />
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>
290
303
  {ModelAndSend}
291
304
  </>
292
305
  ) : (